sofetch-0.1.0.0: src/Fetch/Cache.hs
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE FlexibleContexts #-}
module Fetch.Cache
( CacheRef
, newCacheRef
-- * Lookup
, CacheLookup(..)
, cacheLookup
-- * Allocation & writing
, cacheAllocate
, cacheAllocateForce
, cacheInsert
, cacheInsertError
-- * Eviction
, cacheEvict
, cacheEvictSource
, cacheEvictWhere
-- * Warming & export
, cacheWarm
, cacheContents
) where
import Fetch.Class
import Fetch.IVar
import Data.Dynamic
import Data.HashMap.Strict (HashMap)
import qualified Data.HashMap.Strict as HM
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Data.IORef
import Type.Reflection (SomeTypeRep, someTypeRep)
-- | Internal representation: each entry is a Dynamic wrapping
-- @HashMap k (IVar (Result k))@.
type ResultMap = Map SomeTypeRep Dynamic
-- | A mutable, shared cache. Stores IVars so that in-flight
-- requests can be deduplicated and concurrent readers can
-- block on pending results.
newtype CacheRef = CacheRef (IORef ResultMap)
-- | Create an empty cache.
newCacheRef :: IO CacheRef
newCacheRef = CacheRef <$> newIORef Map.empty
-- ──────────────────────────────────────────────
-- Lookup
-- ──────────────────────────────────────────────
-- | Result of looking up a key in the cache.
data CacheLookup a
= CacheMiss
-- ^ Key has never been requested.
| CacheHitPending (IVar a)
-- ^ Key is being fetched by another round. Wait on the IVar.
| CacheHitReady a
-- ^ Key has a resolved value.
-- | Look up a key in the cache. Distinguishes between miss,
-- pending (in-flight), and ready (resolved).
cacheLookup :: forall k. (FetchKey k, Typeable (Result k))
=> CacheRef -> k -> IO (CacheLookup (Result k))
cacheLookup (CacheRef ref) k = do
cache <- readIORef ref
let trep = someTypeRep (Proxy @k)
case Map.lookup trep cache >>= fromDynamic of
Just (ivars :: HashMap k (IVar (Result k))) ->
case HM.lookup k ivars of
Just iv -> do
mr <- tryReadIVar iv
case mr of
Just (Right v) -> pure (CacheHitReady v)
Just (Left _) -> pure CacheMiss
-- Errored IVars are treated as a miss: allow retry.
Nothing -> pure (CacheHitPending iv)
Nothing -> pure CacheMiss
Nothing -> pure CacheMiss
-- ──────────────────────────────────────────────
-- Allocation
-- ──────────────────────────────────────────────
-- | Atomically allocate IVars for keys not already in the cache.
-- Returns only the newly allocated (key, IVar) pairs; keys that
-- already had IVars (filled or pending) are skipped.
--
-- This is the deduplication point: concurrent calls to
-- @cacheAllocate@ for the same key will only allocate once.
cacheAllocate :: forall k. (FetchKey k, Typeable (Result k))
=> CacheRef -> [k] -> IO [(k, IVar (Result k))]
cacheAllocate (CacheRef ref) keys = do
-- Allocate IVars in IO before the atomic update. Some may be
-- wasted if the key is already cached, but this avoids the
-- unsafePerformIO-inside-atomicModifyIORef pitfall where GHC
-- optimisations can break sharing of thunks.
candidates <- mapM (\k -> do iv <- newIVar; pure (k, iv)) keys
atomicModifyIORef' ref $ \cache ->
let trep = someTypeRep (Proxy @k)
existing :: HashMap k (IVar (Result k))
existing = case Map.lookup trep cache >>= fromDynamic of
Just m -> m
Nothing -> HM.empty
go [] acc ivMap = (acc, ivMap)
go ((k, iv):rest) acc ivMap =
case HM.lookup k ivMap of
Just _ -> go rest acc ivMap
Nothing -> go rest ((k, iv) : acc) (HM.insert k iv ivMap)
(newPairs, updated) = go candidates [] existing
cache' = Map.insert trep (toDyn updated) cache
in (cache', newPairs)
-- | Like 'cacheAllocate', but always creates fresh IVars,
-- overwriting any existing entries for the same keys.
--
-- Used by the engine for 'NoCaching' data sources. The old IVar
-- (if any) is replaced atomically, so:
--
-- * Continuations from the /current/ round all share the new IVar
-- (within-round deduplication is preserved).
-- * Continuations from a /prior/ round that already read the old
-- IVar are unaffected (they completed before the overwrite).
-- * The next round will overwrite again, ensuring the source is
-- re-fetched every time.
cacheAllocateForce :: forall k. (FetchKey k, Typeable (Result k))
=> CacheRef -> [k] -> IO [(k, IVar (Result k))]
cacheAllocateForce (CacheRef ref) keys = do
candidates <- mapM (\k -> do iv <- newIVar; pure (k, iv)) keys
atomicModifyIORef' ref $ \cache ->
let trep = someTypeRep (Proxy @k)
existing :: HashMap k (IVar (Result k))
existing = case Map.lookup trep cache >>= fromDynamic of
Just m -> m
Nothing -> HM.empty
-- Always overwrite: insert every candidate regardless of
-- whether the key already has an IVar in the cache.
updated = foldl (\m (k, iv) -> HM.insert k iv m) existing candidates
cache' = Map.insert trep (toDyn updated) cache
in (cache', candidates)
-- ──────────────────────────────────────────────
-- Writing
-- ──────────────────────────────────────────────
-- | Look up the IVar for a key and apply an action to it.
-- No-op if the key has no allocated IVar.
withCachedIVar :: forall k. (FetchKey k, Typeable (Result k))
=> CacheRef -> k -> (IVar (Result k) -> IO ()) -> IO ()
withCachedIVar (CacheRef ref) k action = do
cache <- readIORef ref
let trep = someTypeRep (Proxy @k)
case Map.lookup trep cache >>= fromDynamic of
Just (ivars :: HashMap k (IVar (Result k))) ->
case HM.lookup k ivars of
Just iv -> action iv
Nothing -> pure ()
Nothing -> pure ()
-- | Write a success result into a previously allocated IVar.
cacheInsert :: forall k. (FetchKey k, Typeable (Result k))
=> CacheRef -> k -> Result k -> IO ()
cacheInsert cRef k v = withCachedIVar cRef k $ \iv -> writeIVar iv v
-- | Write an error into a previously allocated IVar.
cacheInsertError :: forall k. (FetchKey k, Typeable (Result k))
=> CacheRef -> k -> SomeException -> IO ()
cacheInsertError cRef k e = withCachedIVar cRef k $ \iv -> writeIVarError iv e
-- ──────────────────────────────────────────────
-- Eviction
-- ──────────────────────────────────────────────
-- | Evict a single key.
cacheEvict :: forall k. (FetchKey k, Typeable (Result k))
=> CacheRef -> k -> IO ()
cacheEvict (CacheRef ref) k = do
let trep = someTypeRep (Proxy @k)
atomicModifyIORef' ref $ \cache ->
( Map.adjust
(\dyn ->
let ivars = fromDyn dyn (HM.empty :: HashMap k (IVar (Result k)))
in toDyn (HM.delete k ivars))
trep cache
, () )
-- | Evict all cached results for a data source.
cacheEvictSource :: forall k. (Typeable k)
=> CacheRef -> Proxy k -> IO ()
cacheEvictSource (CacheRef ref) _ =
atomicModifyIORef' ref $ \cache ->
(Map.delete (someTypeRep (Proxy @k)) cache, ())
-- | Evict keys matching a predicate.
cacheEvictWhere :: forall k. (FetchKey k, Typeable (Result k))
=> CacheRef -> Proxy k -> (k -> Bool) -> IO ()
cacheEvictWhere (CacheRef ref) _ predicate = do
let trep = someTypeRep (Proxy @k)
atomicModifyIORef' ref $ \cache ->
( Map.adjust
(\dyn ->
let ivars = fromDyn dyn (HM.empty :: HashMap k (IVar (Result k)))
in toDyn (HM.filterWithKey (\k' _ -> not (predicate k')) ivars))
trep cache
, () )
-- ──────────────────────────────────────────────
-- Warming & export
-- ──────────────────────────────────────────────
-- | Warm the cache with known values. Creates pre-filled IVars.
-- Useful for hydrating from an external cache (Redis, etc.)
-- at request start.
cacheWarm :: forall k. (FetchKey k, Typeable (Result k))
=> CacheRef -> HashMap k (Result k) -> IO ()
cacheWarm (CacheRef ref) values = do
let trep = someTypeRep (Proxy @k)
ivars <- HM.traverseWithKey (\_ v -> do
iv <- newIVar
writeIVar iv v
pure iv) values
atomicModifyIORef' ref $ \cache ->
let existing :: HashMap k (IVar (Result k))
existing = case Map.lookup trep cache >>= fromDynamic of
Just m -> m
Nothing -> HM.empty
in (Map.insert trep (toDyn (HM.union ivars existing)) cache, ())
-- | Read all resolved values for a source (for debugging/export).
cacheContents :: forall k. (FetchKey k, Typeable (Result k))
=> CacheRef -> Proxy k -> IO (HashMap k (Result k))
cacheContents (CacheRef ref) _ = do
cache <- readIORef ref
let trep = someTypeRep (Proxy @k)
case Map.lookup trep cache >>= fromDynamic of
Just (ivars :: HashMap k (IVar (Result k))) ->
HM.mapMaybe id <$> traverse (\iv -> do
mr <- tryReadIVar iv
pure $ case mr of
Just (Right v) -> Just v
_ -> Nothing) ivars
Nothing -> pure HM.empty