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)