packages feed

tpar-0.1.0.0: TPar/Server.hs

{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE NamedFieldPuns #-}

module TPar.Server
    ( -- * Workers
      Worker
    , localWorker
    , sshWorker
    , runRemoteWorker
      -- * Running the server
    , server
    , runServer
      -- * Convenience wrappers
    , enqueueAndFollow
    ) where

import Control.Error
import Control.Applicative
import Control.Monad (void, forever, filterM)
import Data.Foldable
import Data.Traversable
import qualified Data.Heap as H
import qualified Data.Map as M
import qualified Data.Set as S
import qualified Data.ByteString as BS
import Control.Monad.Catch
import Control.Distributed.Process hiding (finally, bracket)
import Data.Time.Clock

import System.IO ( openFile, hClose, IOMode(..), Handle )
import System.Exit
import Control.Concurrent.STM

import Pipes
import qualified Pipes.Prelude as P.P

import TPar.Rpc
import TPar.SubPubStream as SubPub
import TPar.ProcessPipe
import TPar.Server.Types
import TPar.Types
import TPar.JobMatch
import TPar.Utils

-----------------------------------------------
-- Convenience wrappers

enqueueAndFollow :: ServerIface -> JobRequest
                 -> Process (Producer ProcessOutput Process ExitCode)
enqueueAndFollow iface jobReq = do
    (rpcSp, rpcRp) <- newRpc
    _jobId <- callRpc (enqueueJob iface) (jobReq, Just rpcSp)
    receiveWait
        [ matchRpc rpcRp $ \subPub -> do
              mprod <- subscribe subPub
              case mprod of
                Just prod -> return ((), prod)
                Nothing   -> fail "enqueueAndFollow: Initial subscription failed"
        ]

-----------------------------------------------
-- Workers

type Worker = JobRequest -> Producer ProcessOutput Process ExitCode

localWorker :: Worker
localWorker req = runProcess (jobCommand req) (jobArgs req) (Just $ jobCwd req)

sshWorker :: String -> FilePath -> Worker
sshWorker host rootPath req = do
    runProcess "ssh" ([host, "--", "cd", cwd, ";", jobCommand req]++jobArgs req) Nothing
  where
    cwd = rootPath ++ "/" ++ jobCwd req  -- HACK

runRemoteWorker :: ServerIface -> Process ()
runRemoteWorker (ServerIface {..}) = forever runOneJob
  where
    runOneJob = do
        doneVar <- liftIO newEmptyTMVarIO
        -- We run each process in a separate thread to ensure that ProcessKilled
        -- exceptions go to the Process that is actually running the job being
        -- killed
        let finished = liftIO $ atomically $ putTMVar doneVar ()
        _pid <- spawnLocal $ flip finally finished $ do
            Right (job, notifyJobStarted, jobFinishedSp) <- callRpc requestJob ()
            tparDebug $ "have job "++show (jobId job)
            result <- runJobWithWorker job notifyJobStarted localWorker
            finishedTime <- liftIO getCurrentTime
            sendChan jobFinishedSp (finishedTime, result)

        liftIO $ atomically $ takeTMVar doneVar

runJobWithWorker :: Job
                 -> JobStartedNotify
                 -> Worker
                 -> Process ExitCode
runJobWithWorker (Job {..}) notifyJobStarted worker =
    withOutFiles (jobSinks jobRequest) $ \intoSinks -> do
        (start, subPub, readRet) <-
            SubPub.fromProducer' $ worker jobRequest >-> intoSinks
        -- wait for confirmation from server before starting
        Right () <- callRpc notifyJobStarted subPub
        start
        either throwM pure =<< liftIO (atomically readRet)
  where
    withOutFiles :: (MonadMask m, MonadIO m)
                 => OutputStreams (Maybe FilePath)
                 -> (Pipe ProcessOutput ProcessOutput m r -> m a)
                 -> m a
    withOutFiles (OutputStreams (Just outPath) (Just errPath)) action
      | outPath == errPath
      = withOutFile outPath $ \hOut ->
        action $ P.P.chain
        $ processOutputToHandles (OutputStreams hOut hOut)

      | otherwise
      = withOutFile outPath $ \hOut ->
        withOutFile errPath $ \hErr ->
        action $ P.P.chain $ processOutputToHandles (OutputStreams hOut hErr)

    withOutFiles (OutputStreams (Just outPath) Nothing) action
      = withOutFile outPath $ \hOut ->
        action $ P.P.chain $ toHandles $ OutputStreams (Just hOut) Nothing

    withOutFiles (OutputStreams Nothing (Just errPath)) action
      = withOutFile errPath $ \hErr ->
        action $ P.P.chain $ toHandles $ OutputStreams Nothing (Just hErr)

    withOutFiles _ action = action cat

    toHandles :: MonadIO m
              => OutputStreams (Maybe Handle)
              -> ProcessOutput -> m ()
    toHandles streams = liftIO . selectStream writeIt
      where
        writeIt :: OutputStreams (BS.ByteString -> IO ())
        writeIt = fmap (maybe (const $ return ()) BS.hPutStr) streams

    withOutFile :: (MonadIO m, MonadMask m)
                => FilePath
                -> (Handle -> m a)
                -> m a
    withOutFile path = bracket (liftIO $ openFile path WriteMode) (liftIO . hClose)

------------------------------------------------
-- the server

-- | Spawn a process running a server
runServer :: Process ServerIface
runServer = do
    q <- liftIO newJobQueue
    iface <- server q
    announce <- spawnLocal $ forever $ do
        x <- expect :: Process (SendPort ServerIface)
        sendChan x iface
    register "tpar" announce
    return iface

-- | The heart of the server
server :: JobQueue -> Process ServerIface
server jobQueue = do
    let protocolVersion = currentProtocolVersion
    (enqueueJob, enqueueJobRp) <- newRpc
    (requestJob, requestJobRp) <- newRpc
    (getQueueStatus, getQueueStatusRp) <- newRpc
    (killJobs, killJobsRp) <- newRpc
    (rerunJobs, rerunJobsRp) <- newRpc

    serverPid <- spawnLocal $ void $ forever $ do
        serverPid <- getSelfPid
        receiveWait
            [ matchRpc enqueueJobRp $ \(jobReq, notifyOriginator) -> do
                  tparDebug "enqueue"
                  jobQueueTime <- liftIO getCurrentTime
                  liftIO $ atomically $ do
                      jobId <- getFreshJobId jobQueue
                      queueJob jobQueue jobId jobQueueTime jobReq notifyOriginator
                      return (jobId, ())

            , matchRpc' requestJobRp $ \workerPid () reply -> do
                  tparDebug $ "request job: "++show workerPid
                  _ <- spawnLocal $ do
                      link serverPid
                      handleJobRequest jobQueue workerPid reply
                  return ()

            , matchRpc getQueueStatusRp $ \jobMatch -> do
                  q <- liftIO $ atomically $ getJobs jobQueue
                  let filtered = filter (jobMatches jobMatch) q
                  return (filtered, ())

            , matchRpc killJobsRp $ \jobMatch -> do
                  killedJobs <- handleKillJobs jobQueue jobMatch
                  return (killedJobs, ())

            , matchRpc rerunJobsRp $ \jobMatch -> do
                  reran <- handleRerunJobs jobQueue jobMatch
                  return (reran, ())
            ]
    return $ ServerIface {..}

handleJobRequest :: JobQueue
                 -> ProcessId
                 -> ((Job, JobStartedNotify, JobFinishedChan) -> Process ())
                 -> Process ()
handleJobRequest jobQueue workerPid reply = do
    -- get a job
    monRef <- monitor workerPid
    job <- liftIO $ atomically $ takeQueuedJob jobQueue

    -- send the job to worker
    (startedSp, startedRp) <- newRpc
    (finishedSp, finishedRp) <- newChan
    reply (job, startedSp, finishedSp)
    let jobid = jobId job
        jobProcessId = workerPid
        jobWorkerNode = processNodeId workerPid
        Queued {jobQueueTime} = jobState job
    jobStartingTime <- liftIO getCurrentTime
    liftIO $ atomically $ setJobState jobQueue jobid (Starting {..})
    tparDebug $ "job "++show jobid++" starting"

    -- wait for started notification
    jobMonitor <- receiveWait
        [matchRpc startedRp $ \jobMonitor -> do
            forM_ (jobStartingNotify job) $ \notifyOriginator -> do
                res <- callRpc notifyOriginator jobMonitor
                case res of
                  Right () -> return ()
                  Left reason ->
                      say $ "Job starting notification to "++show notifyOriginator
                         ++" failed due to "++show reason++". Starting anyways."
            return ((), jobMonitor)
        ]
    jobStartTime <- liftIO getCurrentTime
    liftIO $ atomically $ setJobState jobQueue jobid (Running {..})
    tparDebug $ "job "++show jobid++" running"

    -- wait for result
    receiveWait
        [ matchChan finishedRp $ \(jobFinishTime, jobExitCode) ->
              liftIO $ atomically $ setJobState jobQueue jobid (Finished {..})

        , matchIf (\(ProcessMonitorNotification ref _ _) -> ref == monRef) $
          \(ProcessMonitorNotification _ _ reason) -> do
              tparDebug $ "job "++show jobid++" failed"
              jobFailedTime <- liftIO getCurrentTime
              let jobErrorMsg = show reason
              liftIO $ atomically $ setJobState jobQueue jobid (Failed {..})
        ]
    unmonitor monRef

handleKillJobs :: JobQueue -> JobMatch -> Process [Job]
handleKillJobs jq@(JobQueue {..}) jobMatch = do
    let shouldBeKilled :: Job -> Maybe (Maybe ProcessId, JobId)
        shouldBeKilled job@(Job {..})
          | Running {..} <- jobState
          , jobMatches jobMatch job    = Just (Just jobProcessId, jobId)
          | Queued {..}  <- jobState
          , jobMatches jobMatch job    = Just (Nothing, jobId)
          | otherwise               = Nothing
    jobsToKill <- liftIO $ atomically $ mapMaybe shouldBeKilled <$> getJobs jq
    say $ "killing "++show jobsToKill
    jobKilledTime <- liftIO getCurrentTime
    killed <- forM jobsToKill $ \(pid, jobid) -> do
        maybe (return ()) (flip exit ProcessKilled) pid
        liftIO $ atomically $ do
            oldState <- updateJob jq jobid $ \job ->
              let state' = case jobState job of
                             Queued {..}  -> Killed { jobKilledStartTime = Nothing
                                                    , jobKilledWorkerNode = Nothing
                                                    , ..
                                                    }
                             Running {..} -> Killed { jobKilledStartTime = Just jobStartTime
                                                    , jobKilledWorkerNode = Just $ processNodeId jobProcessId
                                                    , ..
                                                    }
                             s            -> s
              in (job {jobState=state'}, jobState job)
            case oldState of
                Queued {}  -> do
                    modifyTVar jobQueue $ H.fromList . filter (\(_, job') -> job' /= jobid) . toList
                    return $ Just jobid
                Running {} -> return $ Just jobid
                _          -> return Nothing
    let killedSet = S.fromList $ catMaybes killed
    liftIO $ atomically $ filter (\job -> jobId job `S.member` killedSet) <$> getJobs jq

handleRerunJobs :: JobQueue -> JobMatch -> Process [Job]
handleRerunJobs jq@(JobQueue {..}) jobMatch = do
    jobQueueTime <- liftIO getCurrentTime
    liftIO $ atomically $ do
        allJobs <- getJobs jq
        filterM (\job -> reEnqueueJob jq job jobQueueTime)
            $ filter (jobMatches jobMatch) allJobs

-----------------------------------------------------
-- primitives

-- | Our job queue state
data JobQueue = JobQueue { freshJobIds :: TVar [JobId]
                         , jobQueue    :: TVar (H.Heap (Priority, JobId))
                         , jobs        :: TVar (M.Map JobId Job)
                         }

getFreshJobId :: JobQueue -> STM JobId
getFreshJobId (JobQueue {..}) = do
    x:xs <- readTVar freshJobIds
    writeTVar freshJobIds xs
    return x

newJobQueue :: IO JobQueue
newJobQueue = JobQueue <$> newTVarIO [ JobId i | i <- [0..] ]
                       <*> newTVarIO H.empty
                       <*> newTVarIO mempty

takeQueuedJob :: JobQueue -> STM Job
takeQueuedJob jq@(JobQueue {..}) = do
    q <- readTVar jobQueue
    case H.viewMin q of
        Nothing -> retry
        Just ((_, jobid), q') -> do
            writeTVar jobQueue q'
            getJob jq jobid

getJob :: JobQueue -> JobId -> STM Job
getJob (JobQueue {..}) jobId = (M.! jobId) <$> readTVar jobs

updateJob :: JobQueue -> JobId -> (Job -> (Job, a)) -> STM a
updateJob (JobQueue {..}) jobId f = do
    jobsMap <- readTVar jobs
    Just x <- pure $ M.lookup jobId jobsMap
    let (x', r) = f x
    writeTVar jobs $ M.insert jobId x' jobsMap
    return r

setJobState :: JobQueue -> JobId -> JobState -> STM ()
setJobState jobQueue jobId newState =
    updateJob jobQueue jobId (\s -> (s {jobState = newState}, ()))

-- | Place a finished, failed, or killed job back on the run queue.
-- Returns 'True' if re-queued.
reEnqueueJob :: JobQueue -> Job -> UTCTime -> STM Bool
reEnqueueJob jq Job{..} reEnqueueTime
  | reQueuable = do
      setJobState jq jobId (Queued reEnqueueTime)
      modifyTVar (jobQueue jq) $ H.insert (jobPriority jobRequest, jobId)
      return True
  | otherwise = return False
  where
    reQueuable =
      case jobState of
        Queued {}   -> False
        Starting {} -> False
        Running {}  -> False
        Failed {}   -> True
        Finished {} -> True
        Killed {}   -> True

queueJob :: JobQueue -> JobId -> UTCTime -> JobRequest
         -> Maybe JobStartingNotify
         -> STM ()
queueJob (JobQueue {..}) jobId jobQueueTime jobRequest jobStartingNotify = do
    modifyTVar jobQueue $ H.insert (prio, jobId)
    modifyTVar jobs $ M.insert jobId (Job {jobState = Queued {..}, ..})
  where
    prio = jobPriority jobRequest

getJobs :: JobQueue -> STM [Job]
getJobs (JobQueue {..}) =
    M.elems <$> readTVar jobs