packages feed

atelier-core-0.1.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)