Changes: 1. Refactoring and some cleanup of JWT handling code: * Instead of caching AuthResult cache decoded claims (which signature was verified). Validating claims and determining role is done after cache lookup * Cleaned up API so that usage of it is simplified: lookupJwtCache cache key >>= parseClaims configJwtAud time * Handling of JwtCacheState initialization and updates of configuration is encapsulated in Auth.JwtCache module 2. Generic high performance (hopefully) scalable, dynamically resizeable cache implementation based on stm, stm-hamt and sieve algorithm. It also integrates with PostgREST measurements infrastructure providing usage stats (ie. hit ratio, evictions count)
219 lines
8.5 KiB
Haskell
219 lines
8.5 KiB
Haskell
{-|
|
|
Module : PostgREST.Cache.Sieve
|
|
Description : PostgREST cache implementation based on Sieve algorithm.
|
|
|
|
This module provides implementation of a mutable cache on Sieve algorithm.
|
|
-}
|
|
{-# LANGUAGE DataKinds #-}
|
|
{-# LANGUAGE GADTs #-}
|
|
{-# LANGUAGE LambdaCase #-}
|
|
{-# LANGUAGE NamedFieldPuns #-}
|
|
{-# LANGUAGE PolyKinds #-}
|
|
{-# LANGUAGE RecordWildCards #-}
|
|
{-# LANGUAGE RecursiveDo #-}
|
|
{-# LANGUAGE StrictData #-}
|
|
{-# LANGUAGE TupleSections #-}
|
|
|
|
module PostgREST.Cache.Sieve (
|
|
Cache
|
|
, CacheConfig (..)
|
|
, Discard (..)
|
|
, alwaysValid
|
|
, cache
|
|
, cacheIO
|
|
, cached
|
|
)
|
|
where
|
|
|
|
import Control.Concurrent.STM
|
|
import Control.Monad.Extra (whileM)
|
|
import Data.Some
|
|
import qualified Focus as F
|
|
import Protolude hiding (elem, head)
|
|
import qualified StmHamt.SizedHamt as SH
|
|
|
|
data ListNode k v (b :: Bool) = ListNode {
|
|
nextPtr :: NodePtr k v,
|
|
prevNextPtrPtr :: NodePtrPtr k v,
|
|
elem :: NodeElem k v b
|
|
}
|
|
|
|
data NodeElem :: Type -> Type -> Bool -> Type where
|
|
Head :: {
|
|
entries :: SH.SizedHamt (HamtEntry k v),
|
|
finger :: NodePtrPtr k v
|
|
} -> NodeElem k v False
|
|
Entry :: Hashable k => {
|
|
visited :: TVar Bool,
|
|
ekey :: k,
|
|
entryValue :: v
|
|
} -> NodeElem k v True
|
|
|
|
type HamtEntry k v = ListNode k v True
|
|
type AnyNode k v = Some (ListNode k v)
|
|
type NodePtr k v = TVar (AnyNode k v)
|
|
type NodePtrPtr k v = TVar (NodePtr k v)
|
|
|
|
data Discard m v = Refresh (m ()) | Invalid (m v)
|
|
|
|
data Cache m k v = (MonadIO m, Hashable k) => Cache (ListNode k v False) (CacheConfig m k v)
|
|
|
|
data CacheConfig m k v = CacheConfig {
|
|
maxSize :: STM Int,
|
|
load :: k -> m v,
|
|
requestListener :: Bool -> m (),
|
|
evictionListener :: k -> v -> m (),
|
|
validator :: m (k -> v -> Maybe (Discard m v))
|
|
}
|
|
|
|
alwaysValid :: Applicative m => m (k -> v -> Maybe (Discard m v))
|
|
alwaysValid = pure (const . const Nothing)
|
|
|
|
cacheIO :: (MonadIO m, Hashable k) => CacheConfig m k v -> IO (Cache m k v)
|
|
cacheIO = atomically . cache
|
|
|
|
cache :: (MonadIO m, Hashable k) => CacheConfig m k v -> STM (Cache m k v)
|
|
cache cacheConfig = mdo
|
|
tail <- newTVar (Some head)
|
|
entries <- SH.new
|
|
finger <- newTVar tail
|
|
head <- ListNode tail <$> newTVar tail <*> pure Head {..}
|
|
pure $ Cache head cacheConfig
|
|
|
|
cached :: Cache m k v -> k -> m v
|
|
cached (Cache head@ListNode{prevNextPtrPtr=neck, elem=Head{..}} CacheConfig{..}) k = do
|
|
checkValid <- validator
|
|
tryMaybe
|
|
-- Fast path: lookup value, update stats and return the value if found and valid
|
|
((liftIO . atomically) (lookup checkValid) >>= notify (requestListener . isJust) >>= validate)
|
|
-- Slow path: load/calculate value and insert it (if still not found)
|
|
(do
|
|
value <- load k
|
|
whileM (not <$> tryInsert value)
|
|
pure value)
|
|
where
|
|
tryMaybe f notFound = f >>= maybe notFound pure
|
|
|
|
notify = ((<$) <*>)
|
|
|
|
validate = fmap join . traverse (\case
|
|
-- valid value
|
|
(Right v) -> pure $ Just v
|
|
-- refresh value
|
|
(Left (Refresh act)) -> act $> Nothing
|
|
-- discard value and return alt result
|
|
(Left (Invalid res)) -> Just <$> res)
|
|
|
|
lookup checkValid = SH.focus focus (ekey . elem) k entries
|
|
where
|
|
focus = F.Focus
|
|
-- not found
|
|
(pure (Nothing, F.Leave))
|
|
-- found
|
|
-- check entry validity
|
|
(\e@ListNode{elem=Entry{visited, entryValue}} ->
|
|
maybe
|
|
-- entry valid
|
|
(mark visited True $> (Just $ Right entryValue, F.Leave))
|
|
-- entry invalid
|
|
-- remove it
|
|
((removeEntry e $>) . (, F.Remove) . Just . Left)
|
|
(checkValid k entryValue)
|
|
)
|
|
|
|
mark t b = whenM ((/= b) <$> readTVar t) (writeTVar t b)
|
|
|
|
-- perform a single entry eviction and possibly insertion atomically
|
|
-- returning False if could not insert
|
|
-- (either because entry currently pointed by the finger was visited
|
|
-- or because after this entry eviction the cache is still full)
|
|
-- so that other threads don't have to wait when visiting entries.
|
|
-- First check if entry is still not in the cache - this time inside transaction.
|
|
--
|
|
-- Execute evictionListener if an entry was evicted
|
|
tryInsert value = do
|
|
(result, evicted) <- liftIO . atomically $ do
|
|
-- Use SH.focus to performa a single lookup instead of 2
|
|
-- we cannot modify Hamt from inside focus
|
|
-- so if there is any entry to remove
|
|
-- we need to delete it after
|
|
(res, evictedKey) <- SH.focus focus (ekey . elem) k entries
|
|
case evictedKey of
|
|
(Just Entry{ekey=entryKey, entryValue}) -> do
|
|
SH.focus F.delete (ekey . elem) entryKey entries
|
|
pure (res, evictionListener entryKey entryValue)
|
|
Nothing -> pure (res, pure ())
|
|
|
|
evicted $> result
|
|
where
|
|
focus = F.Focus (do
|
|
(hasSpace, evictedKey) <- evictionStep
|
|
if hasSpace then do
|
|
entry <- newLinkedEntry value
|
|
-- done, maybe evicted, insert entry
|
|
pure ((True, evictedKey), F.Set entry)
|
|
else
|
|
-- not done, maybe evicted, don't modify entries
|
|
pure ((False, evictedKey), F.Leave))
|
|
-- Entry found case
|
|
(\ListNode{elem=Entry{visited}} -> do
|
|
-- mark as visited
|
|
mark visited True
|
|
-- done, no evictions, don't modify entries
|
|
pure ((True, Nothing), F.Leave))
|
|
|
|
-- if the cache is full precoesses a single node
|
|
-- removing it if it is marked as unvisited
|
|
-- or clearing visited mark
|
|
-- returns True if there is space in the cache
|
|
-- puts evictionListener in state if an entry was evicted
|
|
evictionStep = do
|
|
currDiff <- liftA2 (-) (SH.size entries) (max 1 <$> maxSize)
|
|
if currDiff >= 0 then do
|
|
-- no space in the cache
|
|
-- need to evict an entry
|
|
(nextFinger, evictedKey) <- readTVar finger >>= evict
|
|
writeTVar finger nextFinger
|
|
-- return if enough space and evicted key if any
|
|
pure (isJust evictedKey && currDiff == 0, evictedKey)
|
|
else
|
|
-- there is space in the cache
|
|
pure (True, Nothing)
|
|
|
|
evict :: TVar (Some (ListNode k v)) -> STM (NodePtr k v, Maybe (NodeElem k v True))
|
|
evict = readTVar >=> \case
|
|
(Some e@ListNode{nextPtr, prevNextPtrPtr, elem=elem@Entry{visited}}) -> do
|
|
ifM (readTVar visited)
|
|
|
|
(writeTVar visited False $> (nextPtr, Nothing))
|
|
|
|
(unlinkEntry e *> fmap (, Just elem) (readTVar prevNextPtrPtr))
|
|
-- skip head
|
|
(Some ListNode{nextPtr, elem=Head{}}) -> evict nextPtr
|
|
|
|
unlinkEntry :: HamtEntry k v -> STM ()
|
|
unlinkEntry (ListNode{nextPtr, prevNextPtrPtr=currPrev}) = do
|
|
nextEntry <- readTVar nextPtr
|
|
withSome nextEntry $ \e -> do
|
|
prevNextPtr <- readTVar currPrev
|
|
writeTVar (prevNextPtrPtr e) prevNextPtr
|
|
writeTVar prevNextPtr nextEntry
|
|
|
|
newLinkedEntry v = do
|
|
oldNeckNextPtr <- readTVar neck
|
|
newNeckNextPtr <- newTVar (Some head)
|
|
newNeck <- ListNode newNeckNextPtr <$>
|
|
newTVar oldNeckNextPtr <*>
|
|
(Entry <$> newTVar False <*> pure k <*> pure v)
|
|
-- update pointers
|
|
writeTVar oldNeckNextPtr (Some newNeck)
|
|
writeTVar neck newNeckNextPtr
|
|
-- return HAMT entry
|
|
pure newNeck
|
|
|
|
removeEntry = fmap (*>) unlinkEntry <*> adjustFinger
|
|
|
|
adjustFinger ListNode{nextPtr, prevNextPtrPtr} =
|
|
whenM ((nextPtr ==) <$> readTVar finger) $
|
|
readTVar prevNextPtrPtr >>= writeTVar finger
|