packages feed

atelier-core-0.1.0.0: src/Atelier/Effects/Cache.hs

-- | Generic key-value cache effect with pluggable eviction strategies
module Atelier.Effects.Cache
    ( -- * Effect
      Cache
    , cacheLookup
    , cacheInsert
    , cacheDelete
    , cacheModify

      -- * Re-exports
    , module Atelier.Effects.Cache.Config

      -- * Interpreters
    , runCacheTtl
    , runCacheTtlWithWait
    , runCacheForever
    ) where

import Data.Time (NominalDiffTime, UTCTime, addUTCTime)
import Effectful (Effect)
import Effectful.Concurrent (Concurrent)
import Effectful.Concurrent.STM (STM)
import Effectful.Dispatch.Dynamic (interpretWith)
import Effectful.Reader.Static (Reader, ask)
import Effectful.TH (makeEffect)
import StmContainers.Map (Map)
import Prelude hiding (Map)

import Effectful.Concurrent.STM qualified as STM
import ListT qualified
import StmContainers.Map qualified as Map

import Atelier.Effects.Cache.Config
import Atelier.Effects.Clock (Clock, currentTime)
import Atelier.Effects.Conc (Conc)
import Atelier.Effects.Delay (Delay)
import Atelier.Effects.Log (Log)
import Atelier.Time (Microsecond, nominalDiffTime)

import Atelier.Effects.Conc qualified as Conc
import Atelier.Effects.Delay qualified as Delay
import Atelier.Effects.Log qualified as Log


-- | Effect for a generic key-value cache.
data Cache key value :: Effect where
    -- | Look up a key, returning 'Nothing' if it is absent.
    CacheLookup :: key -> Cache key value m (Maybe value)
    -- | Insert or overwrite the value at a key.
    CacheInsert :: key -> value -> Cache key value m ()
    -- | Remove a key from the cache.
    CacheDelete :: key -> Cache key value m ()
    -- | Apply a function to a key's current value (or its absence), store the
    -- result, and return it.
    CacheModify :: key -> (Maybe value -> value) -> Cache key value m value


makeEffect ''Cache


-- | Internal entry wrapping a value with its insertion timestamp
data CacheEntry value = CacheEntry
    { value :: !value
    , createdAt :: !UTCTime
    }


-- | Run the Cache effect with TTL-based eviction
--
-- Entries are evicted after @entryTtl@ from their first insertion.
-- Subsequent updates to the same key preserve the original timestamp.
-- A background thread runs every @cleanupInterval@ to remove expired entries.
runCacheTtl
    :: forall key value es a
     . ( Clock :> es
       , Conc :> es
       , Concurrent :> es
       , Delay :> es
       , Hashable key
       , Log :> es
       , Reader Config :> es
       )
    => Eff (Cache key value : es) a -> Eff es a
runCacheTtl act = do
    cfg <- ask
    runCacheTtlWithWait (Delay.wait $ nominalDiffTime @Microsecond cfg.cleanupInterval) act


-- | Like 'runCacheTtl', but with the cleanup cadence supplied explicitly as a
-- waiting action rather than read from 'Config'. Each time the action
-- completes, expired entries are swept. Useful for tests that drive cleanup
-- deterministically.
runCacheTtlWithWait
    :: forall key value es a
     . ( Clock :> es
       , Conc :> es
       , Concurrent :> es
       , Hashable key
       , Log :> es
       , Reader Config :> es
       )
    => Eff es ()
    -- ^ Action that completes when it is time to perform cleanup.
    -> Eff (Cache key value : es) a
    -> Eff es a
runCacheTtlWithWait waitForNextCleanup action = do
    cfg <- ask @Config
    store <- STM.atomically (Map.new :: STM (Map key (CacheEntry value)))

    Conc.fork_ $ forever $ Log.withNamespace "Cache" do
        waitForNextCleanup
        now <- currentTime
        evicted <- STM.atomically $ evictExpiredEntries store cfg.entryTtl now

        when (evicted > 0)
            $ Log.debug
            $ "Evicted " <> show evicted <> " entries"

    interpretWith action $ \_ -> \case
        CacheLookup key ->
            fmap (.value) <$> STM.atomically (Map.lookup key store)
        CacheInsert key val -> do
            now <- currentTime
            STM.atomically $ do
                existing <- Map.lookup key store
                let entry = case existing of
                        Nothing -> CacheEntry {value = val, createdAt = now}
                        Just e -> e {value = val} -- preserve original timestamp
                Map.insert entry key store
        CacheDelete key ->
            STM.atomically $ Map.delete key store
        CacheModify key f -> do
            now <- currentTime
            STM.atomically $ do
                existing <- Map.lookup key store
                let newEntry = case existing of
                        Nothing -> CacheEntry {value = f Nothing, createdAt = now}
                        Just e -> e {value = f (Just e.value)}
                Map.insert newEntry key store
                pure newEntry.value


evictExpiredEntries
    :: (Hashable key)
    => Map key (CacheEntry value)
    -> NominalDiffTime
    -> UTCTime
    -> STM Int
evictExpiredEntries store ttl now =
    ListT.fold
        ( \count (k, v) ->
            if now >= addUTCTime ttl v.createdAt then
                Map.delete k store $> count + 1
            else
                pure count
        )
        0
        $ Map.listT store


-- | Run the Cache effect with no TTL (entries live forever).
--
-- Uses a plain STM 'Map' with no eviction. Useful for tests and
-- session-scoped caches where eviction is never needed.
runCacheForever
    :: forall key value es a
     . (Concurrent :> es, Hashable key)
    => Eff (Cache key value : es) a
    -> Eff es a
runCacheForever action = do
    store <- STM.atomically (Map.new :: STM (Map key value))
    interpretWith action $ \_ -> \case
        CacheLookup key ->
            STM.atomically $ Map.lookup key store
        CacheInsert key val ->
            STM.atomically $ Map.insert val key store
        CacheDelete key ->
            STM.atomically $ Map.delete key store
        CacheModify key f ->
            STM.atomically $ do
                existing <- Map.lookup key store
                let newVal = f existing
                Map.insert newVal key store
                pure newVal