atelier-core-0.1.0.0: src/Atelier/Effects/Cache/Singleflight.hs
-- | A singleflight cache effect: deduplicate concurrent computations of the
-- same key.
--
-- When several threads request the same key at once, the first runs the
-- computation while the rest wait and share its result — so an expensive lookup
-- happens once per key per in-flight window. Results (and exceptions) can also
-- be seeded with 'updateCache' or invalidated with 'removeFromCache'. Each
-- operation is traced (see "Atelier.Effects.Monitoring.Tracing").
module Atelier.Effects.Cache.Singleflight
( Singleflight
, withCache
, updateCache
, removeFromCache
, runSingleflight
) where
import Effectful (Effect)
import Effectful.Concurrent (Concurrent)
import Effectful.Concurrent.STM (TMVar)
import Effectful.Dispatch.Dynamic (interpretWith, localSeqUnlift)
import Effectful.Exception (throwIO, trySync)
import Effectful.TH (makeEffect)
import StmContainers.Map (Map)
import Prelude hiding (Map)
import Effectful.Concurrent.STM qualified as STM
import StmContainers.Map qualified as Map
import Atelier.Effects.Monitoring.Tracing (Tracing, addAttribute, withSpan)
-- | Singleflight cache effect for deduplicating concurrent computations
--
-- When multiple concurrent operations request the same key:
-- - The first request executes the computation
-- - Subsequent requests wait for and share the result
-- - After completion, all requests receive the same result
data Singleflight key value :: Effect where
-- | Execute a computation with singleflight semantics
-- If another computation for the same key is in-flight, wait for its result
WithCache :: (Hashable key) => key -> m value -> Singleflight key value m value
-- | Pre-populate the cache with known values
-- Useful when values are already available to avoid redundant computation
UpdateCache :: [(key, value)] -> Singleflight key value m ()
-- | Remove keys from the cache so future requests recompute from source
RemoveFromCache :: [key] -> Singleflight key value m ()
makeEffect ''Singleflight
type InFlightMap key value = Map key (TMVar (Either SomeException value))
-- | Run the Singleflight effect with an in-memory cache
runSingleflight
:: forall key value es a
. (Concurrent :> es, Hashable key, Tracing :> es)
=> Eff (Singleflight key value : es) a
-> Eff es a
runSingleflight action = do
-- Initialize the cache
cache :: InFlightMap key value <- STM.atomically Map.new
-- Run with the cache in Reader context, interpreting Singleflight operations
interpretWith action $ \env -> \case
WithCache key computation -> localSeqUnlift env $ \unlift -> withSpan "singleflight.with_cache" do
-- Singleflight pattern: check if computation is already in-flight.
-- Also attempt a non-blocking read of any existing result in the same transaction.
(mvar, isFirst, mResult) <- STM.atomically $ do
existing <- Map.lookup key cache
case existing of
Just tmvar -> do
mResult <- STM.tryReadTMVar tmvar
pure (tmvar, False, mResult)
Nothing -> do
-- Create new empty TMVar and insert into cache
tmvar <- STM.newEmptyTMVar
Map.insert tmvar key cache
pure (tmvar, True, Nothing)
case (isFirst, mResult) of
(_, Just (Right value)) -> do
addAttribute @Text "singleflight.outcome" "hit"
pure value
(_, Just (Left exception)) -> do
addAttribute @Text "singleflight.outcome" "hit"
throwIO exception
(True, Nothing) -> do
addAttribute @Text "singleflight.outcome" "compute"
result <- unlift $ trySync computation
-- Try to fill the TMVar with result (success or failure) for waiters
-- Use tryPutTMVar in case updateCache already filled it
filled <- STM.atomically $ STM.tryPutTMVar mvar result
if filled then do
-- We successfully filled the TMVar with our result
-- If it was an exception, remove from cache so future requests can retry
case result of
Left _exception -> do
STM.atomically $ Map.delete key cache
Right _ -> pure ()
-- Return the result or re-throw the exception
case result of
Left exception -> throwIO exception
Right value -> pure value
else do
-- updateCache filled it before us - read and use that value
finalResult <- STM.atomically $ STM.readTMVar mvar
case finalResult of
Left exception -> throwIO exception
Right value -> pure value
(False, Nothing) -> do
addAttribute @Text "singleflight.outcome" "wait"
result <-
withSpan "singleflight.wait"
$ STM.atomically
$ STM.readTMVar mvar
case result of
Left exception -> throwIO exception
Right value -> pure value
UpdateCache entries -> withSpan "singleflight.update_cache" do
STM.atomically $ do
forM_ entries $ \(key, value) -> do
existing <- Map.lookup key cache
case existing of
Just existingTMVar -> do
-- In-flight computation exists: update its result
-- Try to take whatever value is there (or Nothing if empty)
_ <- STM.tryTakeTMVar existingTMVar
-- Put the correct value (wrapped in Right for success)
STM.putTMVar existingTMVar (Right value)
Nothing -> do
-- No in-flight computation: create fresh TMVar with value
tmvar <- STM.newTMVar (Right value)
Map.insert tmvar key cache
RemoveFromCache keys -> withSpan "singleflight.remove_from_cache" do
STM.atomically $ forM_ keys $ \key -> Map.delete key cache