wrecker-ui-2.3.0: src/Scheduler.hs
{-# LANGUAGE RecordWildCards, NamedFieldPuns #-}
{-# LANGUAGE TemplateHaskell #-}
module Scheduler
( RunSchedule
, Config(..)
, ScheduleOptions(..)
, emptyRunSchedule
, runScheduler
, getRunSchedule
, addToQueue
, __remoteTable -- This is created by the call to remotable using TemplateHaskell
) where
import Control.Concurrent (threadDelay)
import Control.Concurrent.Async (Async, async, pollSTM)
import qualified Control.Concurrent.STM as STM
import Control.Monad (guard)
import Data.Aeson
import Data.List.NonEmpty (NonEmpty(..), (!!), fromList)
import qualified Data.Map as Map
import Data.Map (Map)
import qualified Data.Maybe as Maybe
import Data.Maybe (fromMaybe)
import Data.Monoid ((<>))
import Data.Text (Text, pack)
import Data.Time.Clock (getCurrentTime)
import Data.Time.ISO8601 (formatISO8601)
import qualified Invoker as Wrecker
import Model (Database, WreckerRun)
import Prelude hiding ((!!))
import qualified Recorder
import Control.Distributed.Process
(NodeId, Process, getSelfNode, liftIO)
import Control.Distributed.Process.Async
(AsyncResult(..), AsyncTask, asyncLinked, remoteTask, wait)
import qualified Control.Distributed.Process.Async as DAsync
import Control.Distributed.Process.Closure
import Control.Distributed.Process.Internal.Types
(LocalProcess, runLocalProcess)
-- | Represents each of the stages a run can be at
data RunStatus
= Running (Maybe (Async Wrecker.Result))
| Done
| Scheduled ScheduleOptions
| None
-- | Avilable options for executing a run
data ScheduleOptions = ScheduleOptions
{ gName :: Text -- ^ The run group name, used to tag many similar runs with different concurrency
, cStart :: Int -- ^ Concurrency start
, cEnd :: Int -- ^ Concurrency end
, sStep :: Int -- ^ Step size. This is the increment to use to get from start concurrency to end
, time :: Maybe Int -- ^ The amount of time to spend on each of the individual runs
} deriving (Show)
-- | A transactional memory variable containing the status fo the current run and the schedule for future runs
type RunSchedule = STM.TVar (Map Text RunStatus)
-- | Scheduler configuration
data Config = Config
{ db :: Database -- ^ A database instance, used to store the run results
, testsList :: RunSchedule -- ^ The varibale to store the current run and schedule for future runs
, localProcess :: LocalProcess -- ^ This node description
, slaves :: [NodeId] -- ^ A list of worker slaves used to distribute runs accross them
}
-- | Wraps the wrecker function into the Process monad
-- This is used for remotely executing wrecker in other nodes
wrecker :: Wrecker.Command -> Process (Either String WreckerRun)
wrecker = liftIO . Wrecker.wrecker
-- Make the wrecker function ready to be called over the wire on slave nodes
remotable ['wrecker]
emptyRunSchedule :: IO RunSchedule
emptyRunSchedule = do
list <- Wrecker.listGroups
STM.newTVarIO (Map.fromList [(pack t, None) | t <- list])
getRunSchedule :: STM.TVar a -> IO a
getRunSchedule testsList = STM.atomically (STM.readTVar testsList)
-- | A blocking functions that refreshes the schedule state each 5 seconds.
-- This functions is responsible for executing the run jobs as they become available
runScheduler :: Config -> IO ()
runScheduler config@(Config {..}) = do
threadDelay $ 5 * 1000 * 1000 -- wait 5 seconds
maybeSchedule <-
STM.atomically $ do
tests <- STM.readTVar testsList
cleaned <- cleanupFinished tests
STM.writeTVar testsList cleaned
pickOneToRun
process maybeSchedule
runScheduler (Config {..})
where
cleanupFinished tests = do
let running = filter isRunning (Map.toList tests)
polled <- mapM pollTest running
let updated = Map.fromList (Maybe.mapMaybe markAsDone polled)
return (Map.union updated tests)
--
-- | In the pair (title, job) poll the job for a status and return it
pollTest (title, Running (Just stat)) = do
res <- pollSTM stat
return (title, res)
pollTest (title, _) = return (title, Nothing)
--
-- | In the pair (title, status) if status is not Nothing, replace
-- the status with "Done", otherwise returns Nothing
markAsDone (_, Nothing) = Nothing
markAsDone (title, _) = Just (title, Done)
pickOneToRun = do
tests <- STM.readTVar testsList
return (take 1 $ filter isScheduled (Map.toList tests))
--
-- | Selects elements having Scheduled as status
isScheduled element =
case element of
(_, Scheduled _) -> True
_ -> False
--
-- | Selects elements having Running as status
isRunning element =
case element of
(_, Running _) -> True
_ -> False
--
-- | If any test was selected for running, then run it
process maybeSchedule =
case maybeSchedule of
(name, Scheduled schedule):_ -> tryRunningNow config name schedule
_ -> return ()
-- | Adds a new run job to the queue. This function will wither queue the job
-- or return a reason for not being able to do so.
addToQueue :: Text -> ScheduleOptions -> RunSchedule -> IO (Either Text Bool)
addToQueue name schedule testsList =
STM.atomically $ do
tests <- STM.readTVar testsList
case canAddToSchedule name tests of
notPossible@(Left _) -> return notPossible
isPossible -> do
updateStatus testsList name (Scheduled schedule)
return isPossible
-- | Checks wheter or not it is possible to executing the give run right now and
-- if possible, it executes it immediately.
tryRunningNow :: Config -> Text -> ScheduleOptions -> IO ()
tryRunningNow config@(Config {..}) name ScheduleOptions {..} = do
canRun <- STM.atomically (markAsRunning testsList name)
guard canRun
now <- getCurrentTime
let steps = Wrecker.Concurrency <$> createSteps
fTime = pack . formatISO8601 $ now
groupName = gName <> " - " <> fTime
runner = escalateWithRecorder config name time groupName
job <- async (runLocalProcess localProcess $ runner steps)
STM.atomically (updateStatus testsList name (Running $ Just job))
where
createSteps =
if cStart == 1 || cStart == 0
then tail [0,sStep .. cEnd]
else [cStart,(cStart + sStep) .. cEnd]
-- | A helper function used to execute a batch of run from a starting to an ending concurrency level,
-- this helper function will try to execute wrecker remotely if the right conditions are given, and
-- will also record the results in the database.
escalateWithRecorder ::
Config -- ^ The scheduler configuration
-> Text -- ^ The test name to execute
-> Maybe Int -- ^ Ampunt of seconds to spend on each test client
-> Text -- ^ The group name to assign to this test run
-> ([Wrecker.Concurrency] -> Process Wrecker.Result)
escalateWithRecorder Config {db, slaves} name time groupName = do
let secs = Wrecker.Seconds (fromMaybe 10 time) -- Run for 10 seconds if no preference was given
builder = Wrecker.Command name secs
keyGen command = liftIO (Recorder.createRun db groupName command)
executer = remoteWrecker slaves
recorder key result = liftIO (Recorder.record db key result)
Wrecker.escalate builder executer keyGen recorder
-- | Executes a wrecker command in the remote sleves, if any are provided. The local node will always
-- participate in executing some of the load. If no slaves are available, the local node executes the whole run.
remoteWrecker :: [NodeId] -> Wrecker.Command -> Process [Either String WreckerRun]
remoteWrecker slaves command@(Wrecker.Command {concurrency}) = do
thisNode <- getSelfNode
tasks <- mapM asyncLinked (divideWork (fromList $ thisNode : slaves)) -- Execute each task remotely and async
mapM waitFor tasks
where
divideWork :: NonEmpty NodeId -> [AsyncTask (Either String WreckerRun)]
-- Try to comfortably accommodate as much work on a single node as possible
-- and return a list of async tasks that should be executed on the network
divideWork nodes@(selfNode :| _) =
let Wrecker.Concurrency conc_ = concurrency
conc = fromIntegral conc_
in if length slaves == 0 || conc < 2000
then [buildTask command selfNode]
else let (first:rest) =
[ nodes !! (i - 1) -- Return the node at position i
| i <- [1 .. length nodes] -- Select one more node
, (conc / fromIntegral i) >= 1000 -- If there are at least 1000 threads to run on it
]
-- Calculate the number of threads per task to execute
total = fromIntegral (length (first : rest)) -- Get the total available workers
fairDivision = fromRational (conc / total) :: Double -- Divide the number of threads equally
avgConcurrency = floor fairDivision -- Round to integer
remainderConcurrency =
avgConcurrency + -- One of the workers need to get the remainder threads
(ceiling (total * ((conc / total) - fromIntegral avgConcurrency)))
setConcurrency c = command {Wrecker.concurrency = Wrecker.Concurrency c}
--
-- Build the async tasks that should be executed
firstTask = buildTask (setConcurrency remainderConcurrency) first -- First task gets teh remainder
restTasks = fmap (buildTask (setConcurrency avgConcurrency)) rest -- But the rest get the same amount of threads
in firstTask : restTasks
-- | Builds the async task for the givend node and command
-- the "task" is executing a wrecker command on a remote machine
buildTask :: Wrecker.Command -> NodeId -> AsyncTask (Either String WreckerRun)
buildTask command node = remoteTask $(functionTDict 'wrecker) node ($(mkClosure 'wrecker) command)
-- | Waits for a remote Asyn job to finish, and returns its result once it dies or it's done.
waitFor :: DAsync.Async (Either String WreckerRun) -> Process (Either String WreckerRun)
waitFor task = do
asyncResult <- wait task -- Wait for the task to be done
case asyncResult of
AsyncDone res -> return res
AsyncFailed reason -> return (Left (show reason))
AsyncLinkFailed reason -> return (Left (show reason))
_ -> return (Left "Error spawning async wrecker task")
-- | Modifies the test list if it is valid to insert the new schedule
markAsRunning :: RunSchedule -> Text -> STM.STM Bool
markAsRunning testsList name = do
tests <- STM.readTVar testsList
if isRunnable name tests
then do
updateStatus testsList name (Running Nothing)
return True
else return False
-- | Updates the status of a single run in the give run schedule
updateStatus :: RunSchedule -> Text -> RunStatus -> STM.STM ()
updateStatus testsList name s = STM.modifyTVar' testsList (Map.insert name s)
-- | Returns whehter it is ok to schedule or run the given test
-- Left is returned when it is not possible to run the test.
-- Otherwise the test is ok to be set for scheduling
canAddToSchedule :: Text -> Map Text RunStatus -> Either Text Bool
canAddToSchedule name list =
case Map.lookup name list of
Nothing -> Left "Test name does not exist"
Just (Running _) -> Left "This test is still running"
Just _ -> Right True
-- | Checks whether or not a particular run (given its name) can be added to the queue.
isRunnable :: Text -> Map Text RunStatus -> Bool
isRunnable name list =
case Map.lookup name list of
Just (Scheduled _) -> True
_ -> False
----------------------------------
-- JSON Conversions
----------------------------------
instance ToJSON RunStatus where
toJSON (Running _) = String "running"
toJSON Done = String "done"
toJSON (Scheduled _) = String "scheduled"
toJSON None = String "none"