atelier-core-0.1.0.0: test/Unit/Atelier/Effects/Cache/SingleflightSpec.hs
module Unit.Atelier.Effects.Cache.SingleflightSpec (spec_Singleflight) where
import Effectful (IOE, runEff)
import Effectful.Concurrent (Concurrent, runConcurrent)
import Effectful.Exception (catch, throwIO)
import Effectful.State.Static.Shared (State, modify, runState)
import Test.Hspec (Spec, describe, it, shouldBe, shouldThrow)
import Atelier.Effects.Cache.Singleflight (Singleflight, runSingleflight, updateCache, withCache)
import Atelier.Effects.Conc (Conc, runConc)
import Atelier.Effects.Delay (Delay, runDelay)
import Atelier.Effects.Monitoring.Tracing (Tracing, runTracingNoOp)
import Atelier.Time (Millisecond)
import Atelier.Types.Semaphore (Semaphore)
import Atelier.Effects.Conc qualified as Conc
import Atelier.Effects.Delay qualified as Delay
import Atelier.Types.Semaphore qualified as Sem
-- | Test exception type
data TestException = TestException Text
deriving stock (Eq, Show)
deriving anyclass (Exception)
-- | Run a Singleflight test with execution counter
runSingleflightTest
:: Eff [Singleflight Int Int, State Int, Delay, Tracing, Conc, Concurrent, IOE] a
-> IO (a, Int)
runSingleflightTest action =
runEff
. runConcurrent
. runConc
. runTracingNoOp
. runDelay
. runState @Int 0
. runSingleflight @Int @Int
$ action
-- | A computation that increments the execution counter and returns a value
compute :: (State Int :> es) => Int -> Eff es Int
compute value = do
modify @Int (+ 1)
pure value
-- | A slow computation that increments the counter
slowCompute :: (Concurrent :> es, State Int :> es) => Semaphore -> Int -> Eff es Int
slowCompute sem value = do
Sem.wait sem
modify @Int (+ 1)
pure value
spec_Singleflight :: Spec
spec_Singleflight = do
describe "Basic Behaviors" $ do
describe "First request executes computation" $ do
it "executes the computation on first request" $ do
(result, execCount) <- runSingleflightTest $ do
withCache 1 (compute 42)
result `shouldBe` 42
execCount `shouldBe` 1
describe "Cached value returned on second request" $ do
it "returns cached value without re-executing" $ do
(result, execCount) <- runSingleflightTest $ do
r1 <- withCache 1 (compute 42)
r2 <- withCache 1 (compute 42)
pure (r1, r2)
result `shouldBe` (42, 42)
execCount `shouldBe` 1
it "returns cached value across multiple sequential requests" $ do
(results, execCount) <- runSingleflightTest $ do
r1 <- withCache 1 (compute 42)
r2 <- withCache 1 (compute 42)
r3 <- withCache 1 (compute 42)
pure [r1, r2, r3]
results `shouldBe` [42, 42, 42]
execCount `shouldBe` 1
describe "Concurrent requests are deduplicated" $ do
it "executes computation once for concurrent requests" $ do
(results, execCount) <- runSingleflightTest $ do
-- Launch 10 concurrent requests for the same key
asyncs <- replicateM 10 do
sem <- Sem.new
async <- Conc.fork $ withCache 1 (slowCompute sem 42)
pure (sem, async)
traverse_ Sem.set $ fst <$> asyncs
traverse Conc.await $ snd <$> asyncs
all (== 42) results `shouldBe` True
length results `shouldBe` 10
execCount `shouldBe` 1
it "all concurrent waiters receive the same result" $ do
(results, execCount) <- runSingleflightTest $ do
sem <- Sem.new
-- Launch concurrent requests with different delays
a1 <- Conc.fork $ withCache 1 (slowCompute sem 99)
Delay.wait (1 :: Millisecond) -- Ensure first request starts
a2 <- Conc.fork $ withCache 1 (compute 99)
a3 <- Conc.fork $ withCache 1 (compute 99)
Sem.signal sem -- Let first request continue
r1 <- Conc.await a1
r2 <- Conc.await a2
r3 <- Conc.await a3
pure [r1, r2, r3]
results `shouldBe` [99, 99, 99]
execCount `shouldBe` 1
describe "Different keys execute independently" $ do
it "executes separate computations for different keys" $ do
(results, execCount) <- runSingleflightTest $ do
r1 <- withCache 1 (compute 10)
r2 <- withCache 2 (compute 20)
r3 <- withCache 3 (compute 30)
pure [r1, r2, r3]
results `shouldBe` [10, 20, 30]
execCount `shouldBe` 3
it "different keys can run concurrently" $ do
(results, execCount) <- runSingleflightTest $ do
sem <- Sem.newSet
a1 <- Conc.fork $ withCache 1 (slowCompute sem 10)
a2 <- Conc.fork $ withCache 2 (slowCompute sem 20)
a3 <- Conc.fork $ withCache 3 (slowCompute sem 30)
replicateM_ 3 $ Sem.signal sem
r1 <- Conc.await a1
r2 <- Conc.await a2
r3 <- Conc.await a3
pure [r1, r2, r3]
all (\r -> r `elem` [10, 20, 30]) results `shouldBe` True
execCount `shouldBe` 3
describe "UpdateCache pre-populates the cache" $ do
it "returns pre-populated value without executing computation" $ do
(result, execCount) <- runSingleflightTest $ do
updateCache [(1, 99)]
withCache 1 (compute 42)
result `shouldBe` 99
execCount `shouldBe` 0
it "pre-populated values are returned by subsequent requests" $ do
(results, execCount) <- runSingleflightTest $ do
updateCache [(1, 99)]
r1 <- withCache 1 (compute 42)
r2 <- withCache 1 (compute 42)
pure [r1, r2]
results `shouldBe` [99, 99]
execCount `shouldBe` 0
describe "UpdateCache handles multiple entries" $ do
it "correctly handles multiple key-value pairs" $ do
(results, execCount) <- runSingleflightTest $ do
updateCache [(1, 10), (2, 20), (3, 30)]
r1 <- withCache 1 (compute 99)
r2 <- withCache 2 (compute 99)
r3 <- withCache 3 (compute 99)
pure [r1, r2, r3]
results `shouldBe` [10, 20, 30]
execCount `shouldBe` 0
describe "All concurrent waiters receive the result" $ do
it "broadcasts result to all waiting requests" $ do
(results, execCount) <- runSingleflightTest $ do
-- Start one slow computation and many fast waiters
asyncs <- replicateM 20 do
sem <- Sem.new
async <- Conc.fork $ withCache 1 (slowCompute sem 777)
pure (sem, async)
traverse_ Sem.signal $ fst <$> asyncs
traverse Conc.await $ snd <$> asyncs
all (== 777) results `shouldBe` True
length results `shouldBe` 20
execCount `shouldBe` 1
describe "Edge Cases" $ do
describe "Computation throws exception" $ do
it "propagates exception to the first caller" $ do
let action = runSingleflightTest $ do
withCache @Int @Int 1 (throwIO $ TestException "boom")
action `shouldThrow` (\(TestException msg) -> msg == "boom")
it "exception does not get cached" $ do
(result, execCount) <- runSingleflightTest $ do
-- First request throws
_ <-
(withCache @Int @Int 1 (throwIO $ TestException "boom"))
`catch` \(_ :: TestException) -> pure 0
-- Second request should re-execute
withCache 1 (compute 42)
result `shouldBe` 42
execCount `shouldBe` 1
it "propagates exception to all concurrent waiters" $ do
let action = runSingleflightTest $ do
sem <- Sem.new
a1 <- Conc.fork $ withCache @Int @Int 1 (slowCompute sem 42 >> throwIO (TestException "concurrent-boom"))
Delay.wait (1 :: Millisecond)
a2 <- Conc.fork $ withCache @Int @Int 1 (compute 99)
Sem.signal sem
_ <- Conc.await a1
Conc.await a2
action `shouldThrow` (\(TestException msg) -> msg == "concurrent-boom")
describe "UpdateCache on in-flight computation" $ do
it "overrides result of in-flight computation" $ do
(result, execCount) <- runSingleflightTest $ do
sem <- Sem.new
-- Start slow computation
a1 <- Conc.fork $ withCache 1 (slowCompute sem 42)
Delay.wait (1 :: Millisecond) -- Let it start
-- Update cache while computation is running
updateCache [(1, 999)]
-- Both should get the updated value
Sem.signal sem
Conc.await a1
result `shouldBe` 999
execCount `shouldBe` 1
it "waiting requests receive updated value" $ do
(results, execCount) <- runSingleflightTest $ do
sem <- Sem.new
-- Start slow computation and waiters
a1 <- Conc.fork $ withCache 1 (slowCompute sem 42)
Delay.wait (1 :: Millisecond)
a2 <- Conc.fork $ withCache 1 (compute 42)
Delay.wait (1 :: Millisecond)
-- Update while they're all waiting/running
updateCache [(1, 888)]
Sem.signal sem
r1 <- Conc.await a1
r2 <- Conc.await a2
pure [r1, r2]
all (== 888) results `shouldBe` True
execCount `shouldBe` 1