packages feed

atelier-core-0.6.0.0: test/Unit/Atelier/Effects/CacheSpec.hs

module Unit.Atelier.Effects.CacheSpec (spec_Cache) where

import Data.Time (UTCTime (..), addUTCTime, fromGregorian)
import Data.Time.Clock (NominalDiffTime)
import Effectful (runEff)
import Effectful.Concurrent (runConcurrent)
import Effectful.Reader.Static (runReader)
import Effectful.State.Static.Shared (evalState, modify)
import Hedgehog (forAll, (===))
import Test.Hspec (Spec, describe, it, shouldBe)
import Test.Hspec.Hedgehog (hedgehog)

import Effectful.Concurrent.STM qualified as STM
import Hedgehog.Gen qualified as Gen
import Hedgehog.Range qualified as Range

import Atelier.Effects.Cache
    ( Config (..)
    , cacheDelete
    , cacheInsert
    , cacheLookup
    , cacheModify
    , runCacheTtlWithWait
    )
import Atelier.Effects.Clock (runClockState)
import Atelier.Effects.Conc (runConc)
import Atelier.Effects.Delay (runDelay)
import Atelier.Effects.Log (runLogNoOp)


spec_Cache :: Spec
spec_Cache = do
    describe "Basic Operations" do
        it "lookup on absent key returns Nothing" do
            result <- runCacheTest $ cacheLookup 1
            result `shouldBe` Nothing

        it "lookup after insert returns Just value" do
            result <- runCacheTest do
                cacheInsert 1 42
                cacheLookup 1
            result `shouldBe` Just 42

        it "lookup after delete returns Nothing" do
            result <- runCacheTest $ do
                cacheInsert 1 42
                cacheDelete 1
                cacheLookup 1
            result `shouldBe` Nothing

        it "insert twice updates value" do
            result <- runCacheTest $ do
                cacheInsert 1 42
                cacheInsert 1 99
                cacheLookup 1
            result `shouldBe` Just 99

        it "delete on absent key is a no-op" do
            result <- runCacheTest $ do
                cacheDelete 1
                cacheLookup 1
            result `shouldBe` Nothing

    describe "Modify" do
        it "modify on absent key uses Nothing branch" do
            result <- runCacheTest $ cacheModify @Int @Int 1 (maybe 0 (+ 1))
            result `shouldBe` 0

        it "modify on present key uses Just branch" do
            result <- runCacheTest $ do
                cacheInsert 1 10
                cacheModify 1 (maybe 0 (+ 1))
            result `shouldBe` 11

        it "modify returns the new value" do
            result <- runCacheTest $ do
                _ <- cacheModify 1 (maybe 5 (+ 5))
                cacheModify 1 (maybe 5 (+ 5))
            result `shouldBe` 10

    describe "TTL Eviction" do
        it "entry is present before TTL expires" do
            result <- runBase . runCacheTestWithWait (STM.atomically STM.retry) $ do
                cacheInsert 1 42
                cacheLookup 1
            result `shouldBe` Just 42

        it "entry is evicted after cleanup thread fires past TTL" do
            result <- runBase do
                trigger <- STM.atomically STM.newEmptyTMVar
                done <- STM.atomically STM.newEmptyTMVar
                runCacheTestWithWait (cleanupBarrier done trigger) do
                    awaitReady done
                    cacheInsert 1 42
                    v1 <- cacheLookup 1
                    modify (addUTCTime (ttl + 1))
                    stepCleanup trigger done -- run one cleanup, block until it finishes
                    v2 <- cacheLookup 1
                    pure (v1, v2)
            result `shouldBe` (Just 42, Nothing)

        it "entry within TTL survives cleanup" do
            result <- runBase do
                trigger <- STM.atomically STM.newEmptyTMVar
                done <- STM.atomically STM.newEmptyTMVar
                runCacheTestWithWait (cleanupBarrier done trigger) do
                    awaitReady done
                    cacheInsert 1 42
                    modify (addUTCTime (ttl - 1))
                    stepCleanup trigger done
                    cacheLookup 1
            result `shouldBe` Just 42

        it "re-inserted entry retains original TTL window" do
            result <- runBase do
                trigger <- STM.atomically STM.newEmptyTMVar
                done <- STM.atomically STM.newEmptyTMVar
                runCacheTestWithWait (cleanupBarrier done trigger) do
                    awaitReady done
                    cacheInsert 1 42
                    v1 <- cacheLookup 1

                    -- re-insert before TTL expires: updates value but preserves createdAt = t0
                    modify (addUTCTime (ttl - 1))
                    stepCleanup trigger done
                    cacheInsert 1 99
                    v2 <- cacheLookup 1

                    -- advance clock past original TTL
                    modify (addUTCTime 2)
                    stepCleanup trigger done
                    v3 <- cacheLookup 1

                    pure (v1, v2, v3)
            -- createdAt was preserved from the first insert, so the entry is expired and evicted
            result `shouldBe` (Just 42, Just 99, Nothing)

    describe "Properties" do
        it "insert then lookup roundtrips value" $ hedgehog do
            v <- forAll $ Gen.int (Range.linear 0 1000)
            result <- liftIO $ runCacheTest $ do
                cacheInsert 1 v
                cacheLookup 1
            result === Just v

        it "distinct keys have independent values" $ hedgehog do
            m <- forAll $ Gen.int (Range.linear 1 20)
            n <- forAll $ Gen.int (Range.linear 1 20)
            (a, b) <- liftIO $ runCacheTest $ do
                cacheInsert 1 m
                cacheInsert 2 n
                va <- cacheLookup 1
                vb <- cacheLookup 2
                pure (va, vb)
            a === Just m
            b === Just n

        it "interleaved key access is independent" $ hedgehog do
            keys <- forAll $ Gen.list (Range.linear 1 50) Gen.bool
            -- count inserts per key by interleaving
            counts <- liftIO $ runCacheTest $ do
                let step True = cacheModify @Int 1 (maybe 1 (+ 1))
                    step False = cacheModify @Int 2 (maybe 1 (+ 1))
                traverse step keys
            let key1Counts = [c | (k, c) <- zip keys counts, k]
                key2Counts = [c | (k, c) <- zip keys counts, not k]
            key1Counts === [1 .. length key1Counts]
            key2Counts === [1 .. length key2Counts]

        it "concurrent inserts to different keys don't interfere" $ hedgehog do
            n <- forAll $ Gen.int (Range.linear 1 20)
            results <- liftIO $ runCacheTest $ do
                for_ [1 .. n] \i -> cacheInsert i i
                traverse (\i -> cacheLookup i) [1 .. n]
            results === map (Just . id) [1 .. n]

        it "modify is atomic under concurrency" $ hedgehog do
            n <- forAll $ Gen.int (Range.linear 1 50)
            finalVal <- liftIO $ runCacheTest $ do
                for_ [1 .. n] \_ -> cacheModify 1 (maybe 1 (+ 1))
                cacheLookup 1
            finalVal === Just n
  where
    runBase = runEff . runConcurrent
    runCacheTest = runBase . runCacheTestWithWait (STM.atomically STM.retry)
    runCacheTestWithWait wait =
        runLogNoOp
            . runConc
            . runDelay
            . evalState epoch
            . runClockState
            . runReader (Config {entryTtl = ttl, cleanupInterval = 3600})
            . runCacheTtlWithWait @Int @Int wait
    epoch = UTCTime (fromGregorian 1970 1 1) 0
    ttl = 3600 :: NominalDiffTime

    -- Deterministic control over the background cleanup thread, replacing
    -- real-time 'Delay.wait' guesses with a handshake. The thread runs its
    -- injected wait ('cleanupBarrier') once per loop: it acknowledges that the
    -- previous cycle finished (via 'done'), then blocks for the next trigger.
    -- 'stepCleanup' triggers one cycle and blocks until that cycle's eviction has
    -- completed, so look-ups afterwards observe a settled state.
    cleanupBarrier done trigger =
        STM.atomically (STM.putTMVar done ()) >> STM.atomically (STM.takeTMVar trigger)
    awaitReady done = STM.atomically (STM.takeTMVar done)
    stepCleanup trigger done =
        STM.atomically (STM.putTMVar trigger ()) >> STM.atomically (STM.takeTMVar done)