glue-core-0.8.0.0: src/Glue/Race.hs
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE ScopedTypeVariables #-}
-- | Module providing a low-level race of concurrent attempts, for callers that make their own
-- decisions about when to launch another attempt and what to do as each one ends (see
-- 'Glue.Hedging.hedgeAcross' for a ready-made policy built on it).
--
-- Attempts are numbered from 0 in launch order. 'awaitNext' reports each attempt's ending once,
-- earliest index first when several have ended. 'abandon' cancels an attempt and waits until it
-- has actually finished, reporting how it really ended: an attempt that completed just before the
-- cancel reports its result. Leaving 'withRace' abandons every attempt still running.
module Glue.Race
( Race
, AttemptOutcome (..)
, RaceEvent (..)
, withRace
, launch
, awaitNext
, abandon
, abandonAll
, finishedAttempts
) where
import Control.Monad (filterM)
import qualified Data.Map.Strict as M
import Data.Maybe (catMaybes, fromMaybe)
import Glue.Clock
import Glue.Failure
import UnliftIO (Async, AsyncCancelled (..), MonadUnliftIO,
SomeException, fromException)
import UnliftIO.Async (asyncWithUnmask, cancel, pollSTM,
waitCatch)
import UnliftIO.Exception (bracket, mask_)
import UnliftIO.STM (TVar, atomically, modifyTVar', newTVarIO,
orElse, readTVar, retrySTM, stateTVar)
-- | How an attempt ended.
data AttemptOutcome failure r
= AttemptAnswered r
-- ^ The attempt returned a result.
| AttemptFailed failure
-- ^ The attempt failed, as seen by the race's failure mode.
| AttemptCancelled
-- ^ The attempt was abandoned before it ended.
| AttemptCrashed SomeException
-- ^ The attempt threw outside its failure mode: an asynchronous exception, or any exception
-- from a service whose failures are values.
deriving (Show)
-- | What 'awaitNext' observed.
data RaceEvent failure r
= AttemptEnded Int (AttemptOutcome failure r)
-- ^ The attempt with this index ended.
| RaceTimedOut
-- ^ The limit passed with no attempt ending.
| NothingRunning
-- ^ Every launched attempt has already been reported or abandoned.
deriving (Show)
-- | A race of attempts, each producing an @r@ whose failures are seen through a t'FailureMode'.
data Race m failure r = Race
{ raceMode :: FailureMode m r failure
, raceClock :: Clock m
, raceRunning :: TVar (M.Map Int (Async (Either failure r)))
-- ^ Attempts not yet reported or abandoned.
, raceEnded :: TVar (M.Map Int (AttemptOutcome failure r))
, raceNextId :: TVar Int
}
toOutcome :: Either SomeException (Either failure r) -> AttemptOutcome failure r
toOutcome (Right (Right result)) = AttemptAnswered result
toOutcome (Right (Left failure)) = AttemptFailed failure
toOutcome (Left e) = case fromException e of
Just AsyncCancelled -> AttemptCancelled
Nothing -> AttemptCrashed e
-- | Runs a race, abandoning any attempts still running when the body finishes or throws.
withRace :: MonadUnliftIO m => FailureMode m r failure -> Clock m -> (Race m failure r -> m x) -> m x
withRace mode clock = bracket create abandonAll
where
create = Race mode clock <$> newTVarIO M.empty <*> newTVarIO M.empty <*> newTVarIO 0
-- | Launches an attempt, returning its index.
launch :: MonadUnliftIO m => Race m failure r -> m r -> m Int
launch race action = mask_ $ do
attempt <- asyncWithUnmask $ \unmask -> unmask (observeCall (raceMode race) action)
atomically $ do
index <- stateTVar (raceNextId race) (\i -> (i, i + 1))
modifyTVar' (raceRunning race) (M.insert index attempt)
pure index
-- | Waits for the next attempt to end, for at most the given number of milliseconds if given
-- (zero or less only checks). When several have ended, the lowest index is reported first.
awaitNext :: MonadUnliftIO m => Race m failure r -> Maybe Int -> m (RaceEvent failure r)
awaitNext race limitMs =
case limitMs of
Nothing -> atomically next
Just ms
| ms <= 0 -> atomically (next `orElse` pure RaceTimedOut)
| otherwise -> fromMaybe RaceTimedOut <$> clockTimeout (raceClock race) (ms * 1000) (atomically next)
where
next = do
running <- readTVar (raceRunning race)
if M.null running
then pure NothingRunning
else do
polled <- traverse (\(index, attempt) -> fmap ((,) index) <$> pollSTM attempt) (M.toAscList running)
case catMaybes polled of
[] -> retrySTM
(index, result) : _ -> do
let outcome = toOutcome result
modifyTVar' (raceRunning race) (M.delete index)
modifyTVar' (raceEnded race) (M.insert index outcome)
pure (AttemptEnded index outcome)
-- | Cancels an attempt and waits until it has finished, returning how it really ended. An
-- attempt already reported returns its reported outcome; an index never launched reports
-- 'AttemptCancelled'.
abandon :: MonadUnliftIO m => Race m failure r -> Int -> m (AttemptOutcome failure r)
abandon race index = do
(ended, running) <- atomically $ (,) <$> (M.lookup index <$> readTVar (raceEnded race))
<*> (M.lookup index <$> readTVar (raceRunning race))
case (ended, running) of
(Just outcome, _) -> pure outcome
(Nothing, Nothing) -> pure AttemptCancelled
(Nothing, Just attempt) -> do
cancel attempt
outcome <- toOutcome <$> waitCatch attempt
atomically $ do
modifyTVar' (raceRunning race) (M.delete index)
modifyTVar' (raceEnded race) (M.insert index outcome)
pure outcome
-- | Abandons every attempt not yet reported, in index order, returning how each ended.
abandonAll :: MonadUnliftIO m => Race m failure r -> m [(Int, AttemptOutcome failure r)]
abandonAll race = do
running <- atomically $ M.keys <$> readTVar (raceRunning race)
traverse (\index -> (,) index <$> abandon race index) running
-- | Indices of attempts that have ended but not yet been reported by 'awaitNext'.
finishedAttempts :: MonadUnliftIO m => Race m failure r -> m [Int]
finishedAttempts race = atomically $ do
running <- M.toAscList <$> readTVar (raceRunning race)
fmap fst <$> filterM (fmap (maybe False (const True)) . pollSTM . snd) running