packages feed

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

{-# LANGUAGE BangPatterns        #-}
{-# LANGUAGE FlexibleContexts    #-}
{-# LANGUAGE RankNTypes          #-}
{-# LANGUAGE RecordWildCards     #-}
{-# LANGUAGE ScopedTypeVariables #-}

-- | Module containing a form of caching where values for given keys are preloaded ahead of time.
-- Once warmed up, requests for preloaded keys will be instant, with the values refreshed in the background.
module Glue.Preload
  ( PreloadedOptions (..)
  , PreloadStoppedBeforeExecutedException (..)
  , defaultPreloadedOptions
  , preloadingService
  , preloadingCall
  ) where

import           Control.Monad       (void)
import           Data.Hashable       (Hashable)
import qualified Data.HashMap.Strict as M
import qualified Data.HashSet        as S
import           Data.Maybe          (fromJust)
import           Glue.Types
import           UnliftIO            (Exception, MonadIO, MonadUnliftIO,
                                      SomeException (..), liftIO)
import           UnliftIO.Async      (race_)
import           UnliftIO.Concurrent (forkIO, threadDelay)
import           UnliftIO.Exception  (throwIO)
import           UnliftIO.IORef      (IORef, atomicModifyIORef', newIORef,
                                      readIORef)
import           UnliftIO.MVar       (MVar, newEmptyMVar, readMVar, tryPutMVar)

-- | Options for determining behaviour of preloading services.
data PreloadedOptions m a b = PreloadedOptions
  { preloadedKeys           :: S.HashSet a
  -- ^ Keys to preload.
  , preloadingRefreshTimeMs :: Int
  -- ^ Amount of time between refreshes.
  , preloadingRun           :: MToIO m
  -- ^ Get an 'IO' of the response for the caching.
  }

-- | Defaulted options for preloading a 'S.HashSet' of keys with a 30 second refresh time.
defaultPreloadedOptions :: S.HashSet a -> MToIO m -> PreloadedOptions m a b
defaultPreloadedOptions toPreload pRun =
  PreloadedOptions
    { preloadedKeys           = toPreload
    , preloadingRefreshTimeMs = 30 * 1000
    , preloadingRun           = pRun
    }

data PreloadedState a b
  = PreloadedStarted
  | PreloadedWithResult (FailOrSuccess a b) Bool

-- | Exception thrown when preloading is stopped before the initial preload has executed.
data PreloadStoppedBeforeExecutedException = PreloadStoppedBeforeExecutedException
  deriving (Eq, Show)

instance Exception PreloadStoppedBeforeExecutedException

modifyStateWithResult :: Either SomeException (MultiGetResponse a b) -> PreloadedState a b -> (PreloadedState a b, Bool)
modifyStateWithResult result PreloadedStarted          = (PreloadedWithResult result True, True)
modifyStateWithResult result (PreloadedWithResult _ c) = (PreloadedWithResult result c, c)

applyResultToState :: MonadIO m => IORef (PreloadedState a b) -> MVar () -> FailOrSuccess a b -> m Bool
applyResultToState stateRef readyVar result = do
  continue <- atomicModifyIORef' stateRef $ modifyStateWithResult result
  void $ tryPutMVar readyVar ()
  pure continue

waitForResult :: MonadIO m => IORef (PreloadedState a b) -> MVar () -> m (MultiGetResponse a b)
waitForResult stateRef readyVar = do
  readMVar readyVar
  state <- readIORef stateRef
  case state of
    PreloadedStarted                      -> throwIO PreloadStoppedBeforeExecutedException
    PreloadedWithResult (Right success) _ -> pure success
    PreloadedWithResult (Left failure)  _ -> throwIO failure

markAsFinished :: PreloadedState a b -> (PreloadedState a b, ())
markAsFinished PreloadedStarted          = (PreloadedWithResult (Left (SomeException PreloadStoppedBeforeExecutedException)) False, ())
markAsFinished (PreloadedWithResult r _) = (PreloadedWithResult r False, ())

-- | Preloads the results of calls for given keys.
preloadingService
  :: forall m n a b.
     (MonadUnliftIO m, MonadIO n, Hashable a)
  => PreloadedOptions m a b
  -- ^ Instance of t'PreloadedOptions' to configure the preloading functionality.
  -> MultiGetService m a b
  -- ^ The service to perform preloading of.
  -> n (MultiGetService m a b, () -> n ())
preloadingService PreloadedOptions{..} service = do
  !stateIORef <- newIORef PreloadedStarted
  !readyVar   <- newEmptyMVar
  !stopVar    <- newEmptyMVar
  let stop _ = do
        atomicModifyIORef' stateIORef markAsFinished
        void $ tryPutMVar readyVar ()
        void $ tryPutMVar stopVar ()
  let runUpdate = do
        result <- makeCall service preloadedKeys
        applyResultToState stateIORef readyVar result
  let updatePreloaded = do
        continue <- liftIO $ preloadingRun runUpdate
        if continue
          then do
            stopped <- liftIO $ raceWait stopVar (preloadingRefreshTimeMs * 1000)
            if stopped then pure () else updatePreloaded
          else pure ()
  let plService request = do
        let fromPreloadKeys = S.intersection request preloadedKeys
        let fromServiceKeys = S.difference request preloadedKeys
        !fromPreload <-
          if S.null fromPreloadKeys
            then pure M.empty
            else M.filterWithKey (\k _ -> S.member k fromPreloadKeys) <$> waitForResult stateIORef readyVar
        !fromService <-
          if S.null fromServiceKeys
            then pure M.empty
            else service fromServiceKeys
        pure $ M.union fromService fromPreload
  void $ liftIO $ forkIO updatePreloaded
  pure (plService, stop)
  where
    raceWait :: MVar () -> Int -> IO Bool
    raceWait stopVar delayUs = do
      race_ (readMVar stopVar) (threadDelay delayUs)
      not <$> tryPutMVar stopVar ()

-- | Preloads a single parameter-less call.
preloadingCall
  :: forall m n b.
     (MonadUnliftIO m, MonadIO n)
  => MToIO m
  -- ^ Get an 'IO' of the call for the caching.
  -> Int
  -- ^ Amount of time between refreshes.
  -> m b
  -- ^ The call to perform preloading of.
  -> n (m b, () -> n ())
preloadingCall toIO timeBetweenRefreshes call = do
  let multiGetService = basicToMultiGet (\_ -> call) :: MultiGetService m () b
  let options = PreloadedOptions (S.singleton ()) timeBetweenRefreshes toIO
  (preloading, stop) <- preloadingService options multiGetService
  let preloadingBasicService = multiGetToBasic preloading
  let preloadingCallResult = fromJust <$> preloadingBasicService ()
  pure (preloadingCallResult, stop)