freckle-http-0.4.1.0: library/Freckle/App/Http/Cache/InProcess.hs
-- | An in-process, size-bounded cache of upstream HTTP responses
--
-- 'cacheGet' checks only the entry being looked up: if its own TTL has
-- already elapsed, it is evicted and treated as a miss. 'cacheSet' evicts
-- only when the total size is over budget, preferring an already-expired
-- entry over the least-recently-used one. Neither walks the whole cache on
-- every call. Call 'cacheReap' yourself (e.g. from a periodic background
-- thread) for more proactive reclamation than that.
module Freckle.App.Http.Cache.InProcess
( InProcessHttpCache
, InProcessHttpCacheSettings (..)
, defaultSettings
, newInProcessHttpCache
, inProcessHttpCache
, inProcessHttpCacheSettings
, cacheGet
, Reap (..)
, cacheGetReap
, cacheSet
, cacheDelete
, cacheReap
) where
import Prelude
import Blammo.Logging (MonadLogger, logDebugNS, logWarnNS)
import Control.Exception.Annotated.UnliftIO (try)
import Control.Monad.IO.Class (MonadIO, liftIO)
import Data.ByteString qualified as BS
import Data.HashPSQ (HashPSQ)
import Data.HashPSQ qualified as HashPSQ
import Data.IORef (IORef, atomicModifyIORef', newIORef)
import Data.Time (UTCTime, addUTCTime, getCurrentTime)
import Database.Memcache.Types (Key, Value)
import Freckle.App.Http.Cache
import Freckle.App.Http.Cache.Memcached (memcachedHttpCodec)
import Freckle.App.Memcached.CacheKey (fromCacheKey)
import Freckle.App.Memcached.CacheTTL (CacheTTL)
import UnliftIO (MonadUnliftIO)
-- | A monotonically increasing counter used as a recency priority
--
-- Smaller means less recently used.
type Tick = Int
data Entry = Entry
{ value :: Value
, expiresAt :: UTCTime
}
data CacheState = CacheState
{ byRecency :: HashPSQ Key Tick Entry
, byExpiry :: HashPSQ Key UTCTime ()
, totalBytes :: Int
, nextTick :: Tick
}
data InProcessHttpCache = InProcessHttpCache
{ ref :: IORef CacheState
, maxBytes :: Int
, clock :: IO UTCTime
}
data InProcessHttpCacheSettings = InProcessHttpCacheSettings
{ maxBytes :: Int
, clock :: IO UTCTime
-- ^ What to use as "now". Tests can override this to simulate a TTL
-- having elapsed without an actual delay.
}
-- | 20MB, using the real clock
defaultSettings :: InProcessHttpCacheSettings
defaultSettings =
InProcessHttpCacheSettings
{ maxBytes = 20 * 1024 * 1024
, clock = getCurrentTime
}
-- | Create an empty cache
newInProcessHttpCache
:: MonadIO m => InProcessHttpCacheSettings -> m InProcessHttpCache
newInProcessHttpCache InProcessHttpCacheSettings {maxBytes, clock} = do
ref <-
liftIO $
newIORef
CacheState
{ byRecency = HashPSQ.empty
, byExpiry = HashPSQ.empty
, totalBytes = 0
, nextTick = 0
}
pure InProcessHttpCache {ref, maxBytes, clock}
inProcessHttpCacheSettings
:: (MonadLogger m, MonadUnliftIO m)
=> InProcessHttpCache
-> CacheTTL
-- ^ Default TTL, used when @max-age@ is not present
-> HttpCacheSettings m Value
inProcessHttpCacheSettings cache defaultTTL =
HttpCacheSettings
{ shared = True
, cacheable = const True
, cacheByHeaders = []
, forceTTL = Nothing
, defaultTTL
, getCurrentTime = liftIO getCurrentTime
, logDebug = logDebugNS "http.cache"
, logWarn = logWarnNS "http.cache"
, codec = memcachedHttpCodec
, cache = inProcessHttpCache cache
}
inProcessHttpCache :: MonadUnliftIO m => InProcessHttpCache -> HttpCache m Value
inProcessHttpCache cache =
HttpCache
{ get = try . liftIO . cacheGet cache . fromCacheKey
, set = \k v ttl -> try $ liftIO $ cacheSet cache (fromCacheKey k) v ttl
, evict = try . liftIO . cacheDelete cache . fromCacheKey
}
-- | Whether a 'cacheGetReap' call should evict an expired looked-up entry
data Reap = Reap | NoReap
cacheGet :: InProcessHttpCache -> Key -> IO (Maybe Value)
cacheGet = cacheGetReap Reap
-- | 'cacheGet', with the choice of whether it checks the looked-up entry's TTL
--
-- Real callers always want 'Reap' (that's what 'cacheGet' fixes it to);
-- 'NoReap' exists so tests can observe an expired entry's continued
-- presence, and 'cacheReap's effect on it, without 'cacheGet's own check
-- masking either.
cacheGetReap :: Reap -> InProcessHttpCache -> Key -> IO (Maybe Value)
cacheGetReap reap InProcessHttpCache {ref, clock} k = do
now <- clock
atomicModifyIORef' ref $ \state -> case HashPSQ.lookup k state.byRecency of
Nothing -> (state, Nothing)
Just (_tick, entry)
| expired -> (removeKey k state, Nothing)
| otherwise ->
( state
{ byRecency = HashPSQ.insert k state.nextTick entry state.byRecency
, nextTick = state.nextTick + 1
}
, Just entry.value
)
where
expired = case reap of
Reap -> entry.expiresAt <= now
NoReap -> False
cacheSet :: InProcessHttpCache -> Key -> Value -> CacheTTL -> IO ()
cacheSet InProcessHttpCache {ref, maxBytes, clock} k v ttl = do
now <- clock
let expiresAt = addUTCTime (fromIntegral ttl) now
atomicModifyIORef' ref $ \state ->
let
oldSize = maybe 0 (BS.length . value . snd) $ HashPSQ.lookup k state.byRecency
state1 =
state
{ byRecency =
HashPSQ.insert k state.nextTick Entry {value = v, expiresAt} state.byRecency
, byExpiry = HashPSQ.insert k expiresAt () state.byExpiry
, totalBytes = state.totalBytes - oldSize + BS.length v
, nextTick = state.nextTick + 1
}
in
(evictToFit maxBytes now state1, ())
cacheDelete :: InProcessHttpCache -> Key -> IO ()
cacheDelete InProcessHttpCache {ref} k =
atomicModifyIORef' ref $ \state -> (removeKey k state, ())
-- | Remove every entry whose TTL has already elapsed
--
-- Unlike 'cacheGet' and 'cacheSet', which only ever look at what that one
-- call needs to, this walks the whole cache. Call it yourself, e.g.
-- periodically from your own background thread, for more proactive
-- reclamation than ordinary traffic gives you; this module does not run one
-- itself.
cacheReap :: InProcessHttpCache -> IO ()
cacheReap InProcessHttpCache {ref, clock} = do
now <- clock
atomicModifyIORef' ref $ \state -> (reapExpired now state, ())
-- | 'cacheReap's implementation: remove every entry whose TTL has elapsed
reapExpired :: UTCTime -> CacheState -> CacheState
reapExpired now = go
where
go state = case HashPSQ.findMin state.byExpiry of
Just (k, expiresAt, ()) | expiresAt <= now -> go (removeKey k state)
_ -> state
-- | Evict entries until under budget, preferring an already-expired one
evictToFit :: Int -> UTCTime -> CacheState -> CacheState
evictToFit maxBytes now = go
where
go state
| state.totalBytes <= maxBytes = state
| otherwise = case popExpired state of
Just state' -> go state'
Nothing -> case popLru state of
Just state' -> go state'
Nothing -> state
popExpired state = case HashPSQ.findMin state.byExpiry of
Just (k, expiresAt, ()) | expiresAt <= now -> Just $ removeKey k state
_ -> Nothing
popLru state = case HashPSQ.findMin state.byRecency of
Just (k, _tick, _entry) -> Just $ removeKey k state
Nothing -> Nothing
removeKey :: Key -> CacheState -> CacheState
removeKey k state =
state
{ byRecency = HashPSQ.delete k state.byRecency
, byExpiry = HashPSQ.delete k state.byExpiry
, totalBytes = state.totalBytes - size
}
where
size = maybe 0 (BS.length . value . snd) $ HashPSQ.lookup k state.byRecency