on master help sign in
Add ability to kill ongoing job

This adds the ability to kill a running job from the web UI, by adding an action to the menu for running jobs. When the "kill" button is clicked, the job is marked for cancellation and an asynchronous poller is responsible for reaping it later. This adds a new status "Cancelled", which represents a job that has been manually killed and is either waiting to be reaped, or has been reaped and can be queued again. To allow for cancelling job threads by ID, the internal runner now tracks job IDs alongside the spawned threads. Co-authored-by: Isaac van Bakel <isaac@supercede.com>

commitc47ebcd400493617f98251307265cb5b5caf2f1b authorPavan Varma <pavan.varma.msp@gmail.com> dateMon 2 Aug 2021, 18:35 (5 years ago) parent18d5b76
10 files changed+182−19
examples/OddJobsCliExample.lhs+2−2 odd-jobs.cabal+4 package.yaml+1−1 src/OddJobs/ConfigBuilder.hs+10 src/OddJobs/Endpoints.hs+12 src/OddJobs/Job.hs+89−12 src/OddJobs/Migrations.hs+3−1 src/OddJobs/Types.hs+12−2 src/OddJobs/Web.hs+25−1 test/Test.hs+24
examples/OddJobsCliExample.lhs+2−2
@@ -72,11 +72,11 @@ In this example, the core job-runner function is in the `IO` monad. In all proba
7272myJobRunner :: Job -> IO ()
7373myJobRunner job = do
7474 throwParsePayload job >>= \case
75− SendWelcomeEmail _userId -> do
75+ SendWelcomeEmail userId -> do
7676 putStrLn $ "This should call the function that actually sends the welcome email. " <>
7777 "\nWe are purposely waiting 60 seconds before completing this job so that graceful shutdown can be demonstrated."
7878 delaySeconds (Seconds 60)
79− putStrLn "60 second wait is now over..."
79+ putStrLn $ "SendWelcomeEmail to user: " <> show userId <> " complete (60 second wait is now over...)"
8080 SendPasswordResetEmail _tkn ->
8181 putStrLn "This should call the function that actually sends the password-reset email"
8282 SetupSampleData _userId -> do
odd-jobs.cabal+4
@@ -75,6 +75,8 @@ library
7575 , async ==2.2.4
7676 , base >=4.7 && <5
7777 , bytestring
78+ , containers
79+ , daemons
7880 , directory
7981 , either
8082 , fast-logger
@@ -137,6 +139,8 @@ executable devel
137139 , async ==2.2.4
138140 , base >=4.7 && <5
139141 , bytestring
142+ , containers
143+ , daemons
140144 , directory
141145 , either
142146 , fast-logger
package.yaml+1−1
@@ -85,6 +85,7 @@ dependencies:
8585 - servant-server
8686 - servant-lucid
8787 - warp
88+ - containers
8889 - unordered-containers
8990 - optparse-applicative
9091 - filepath
@@ -149,4 +150,3 @@ tests:
149150 - mmorph
150151 - lifted-base
151152 - lifted-async
152− - containers
src/OddJobs/ConfigBuilder.hs+10
@@ -144,6 +144,10 @@ defaultLogStr jobTypeFn logLevel logEvent =
144144 LogJobTimeout j@Job{jobLockedAt, jobLockedBy} ->
145145 "Timeout | " <> jobToLogStr j <> " | lockedBy=" <> toLogStr (maybe "unknown" unJobRunnerName jobLockedBy) <>
146146 " lockedAt=" <> toLogStr (maybe "unknown" show jobLockedAt)
147+ LogKillJobSuccess j ->
148+ "Kill Job Success | " <> jobToLogStr j
149+ LogKillJobFailed j ->
150+ "Kill Job Failed | " <> jobToLogStr j <> "(the job might have completed or timed out)"
147151 LogPoll ->
148152 "Polling jobs table"
149153 LogWebUIRequest ->
@@ -321,6 +325,12 @@ defaultJsonLogEvent logEvent =
321325 LogJobTimeout job ->
322326 Aeson.object [ "tag" Aeson..= ("LogJobTimeout" :: Text)
323327 , "contents" Aeson..= defaultJsonJob job ]
328+ LogKillJobSuccess job ->
329+ Aeson.object [ "tag" Aeson..= ("LogKillJobSuccess" :: Text)
330+ , "contents" Aeson..= defaultJsonJob job ]
331+ LogKillJobFailed job ->
332+ Aeson.object [ "tag" Aeson..= ("LogKillJobFailed" :: Text)
333+ , "contents" Aeson..= defaultJsonJob job ]
324334 LogPoll ->
325335 Aeson.object [ "tag" Aeson..= ("LogJobPoll" :: Text)]
326336 LogWebUIRequest ->
src/OddJobs/Endpoints.hs+12
@@ -60,6 +60,7 @@ data Routes route = Routes
6060 , rEnqueue :: route :- "enqueue" :> Capture "jobId" JobId :> Post '[HTML] NoContent
6161 , rRunNow :: route :- "run" :> Capture "jobId" JobId :> Post '[HTML] NoContent
6262 , rCancel :: route :- "cancel" :> Capture "jobId" JobId :> Post '[HTML] NoContent
63+ , rKill :: route :- "kill" :> Capture "jobId" JobId :> Post '[HTML] NoContent
6364 , rRefreshJobTypes :: route :- "refresh-job-types" :> Post '[HTML] NoContent
6465 , rRefreshJobRunners :: route :- "refresh-job-runners" :> Post '[HTML] NoContent
6566 } deriving (Generic)
@@ -114,6 +115,7 @@ server cfg env nt =
114115 { rFilterResults = nt . filterResults cfg env
115116 , rEnqueue = nt . enqueueJob cfg env
116117 , rCancel = nt . cancelJob cfg env
118+ , rKill = nt . killJob cfg env
117119 , rRunNow = nt . runJobNow cfg env
118120 , rRefreshJobTypes = nt $ refreshJobTypes cfg env
119121 , rRefreshJobRunners = nt $ refreshJobRunners cfg env
@@ -126,6 +128,7 @@ server2 cfg env = Routes
126128 { rFilterResults = filterResults cfg env
127129 , rEnqueue = enqueueJob cfg env
128130 , rCancel = cancelJob cfg env
131+ , rKill = killJob cfg env
129132 , rRunNow = runJobNow cfg env
130133 , rRefreshJobTypes = refreshJobTypes cfg env
131134 , rRefreshJobRunners = refreshJobRunners cfg env
@@ -156,6 +159,14 @@ cancelJob UIConfig{..} env jid = do
156159 liftIO $ withResource uicfgDbPool $ \conn -> void $ cancelJobIO conn uicfgTableName jid
157160 redirectToHome env
158161
162+killJob :: UIConfig
163+ -> Env
164+ -> JobId
165+ -> Handler NoContent
166+killJob UIConfig{..} env jid = do
167+ liftIO $ withResource uicfgDbPool $ \conn -> void $ killJobIO conn uicfgTableName jid
168+ redirectToHome env
169+
159170runJobNow :: UIConfig
160171 -> Env
161172 -> JobId
@@ -201,6 +212,7 @@ routes linkFn = Web.Routes
201212 , Web.rEnqueue = rEnqueue
202213 , Web.rRunNow = rRunNow
203214 , Web.rCancel = rCancel
215+ , Web.rKill = rKill
204216 , Web.rRefreshJobTypes = rRefreshJobTypes
205217 , Web.rRefreshJobRunners = rRefreshJobRunners
206218 , Web.rStaticAsset = linkFn
src/OddJobs/Job.hs+89−12
@@ -65,6 +65,7 @@ module OddJobs.Job
6565 , runJobNowIO
6666 , unlockJobIO
6767 , cancelJobIO
68+ , killJobIO
6869 , jobDbColumns
6970 , jobPollingIO
7071 , concatJobDbColumns
@@ -110,14 +111,16 @@ import Data.Aeson hiding (Success)
110111import qualified Data.Aeson as Aeson
111112import qualified Data.Aeson.Types as Aeson (Parser, parseMaybe)
112113import Data.String.Conv (StringConv(..), toS)
113−import Data.Functor (void)
114+import Data.Functor ((<&>), void)
114115import Control.Monad (forever, forM_, join)
115116import Data.Maybe (isNothing, maybe, fromMaybe, listToMaybe, mapMaybe)
116117import Data.Either (either)
117118import Control.Monad.Reader
118119import GHC.Generics
120+import Data.Map (Map)
119121import qualified Data.HashMap.Strict as HM
120122import qualified Data.List as DL
123+import qualified Data.Map as DM
121124import qualified Data.ByteString as BS
122125import qualified Data.ByteString.Lazy as BSL
123126import System.FilePath (FilePath)
@@ -169,7 +172,7 @@ class (MonadUnliftIO m, MonadBaseControl IO m) => HasJobRunner m where
169172
170173data RunnerEnv = RunnerEnv
171174 { envConfig :: !Config
172− , envJobThreadsRef :: !(IORef [Async ()])
175+ , envJobThreadsRef :: !(IORef (Map JobId (Async ())))
173176 }
174177
175178type RunnerM = ReaderT RunnerEnv IO
@@ -218,7 +221,7 @@ instance HasJobRunner RunnerM where
218221startJobRunner :: Config -> IO ()
219222startJobRunner jm = do
220223 traceM "startJobRunner top"
221− r <- newIORef []
224+ r <- newIORef DM.empty
222225 let monitorEnv = RunnerEnv
223226 { envConfig = jm
224227 , envJobThreadsRef = r
@@ -346,7 +349,7 @@ deleteJobIO conn tname jid = do
346349runJobNowIO :: Connection -> TableName -> JobId -> IO (Maybe Job)
347350runJobNowIO conn tname jid = do
348351 t <- getCurrentTime
349− updateJobHelper tname conn (Queued, [Queued, Retry, Failed], Just t, jid)
352+ updateJobHelper tname conn (Queued, [Queued, Retry, Failed, Cancelled], Just t, jid)
350353
351354-- | TODO: First check in all job-runners if this job is still running, or not,
352355-- and somehow send an uninterruptibleCancel to that thread.
@@ -358,7 +361,11 @@ unlockJobIO conn tname jid = do
358361
359362cancelJobIO :: Connection -> TableName -> JobId -> IO (Maybe Job)
360363cancelJobIO conn tname jid =
361− updateJobHelper tname conn (Failed, [Queued, Retry], Nothing, jid)
364+ updateJobHelper tname conn (Cancelled, [Queued, Retry], Nothing, jid)
365+
366+killJobIO :: Connection -> TableName -> JobId -> IO (Maybe Job)
367+killJobIO conn tname jid =
368+ updateJobHelper tname conn (Cancelled, [Locked], Nothing, jid)
362369
363370updateJobHelper :: TableName
364371 -> Connection
@@ -383,13 +390,17 @@ runJobWithTimeout :: (HasJobRunner m)
383390 => Seconds
384391 -> Job
385392 -> m ()
386−runJobWithTimeout timeoutSec job = do
393+runJobWithTimeout timeoutSec job@Job{jobId} = do
387394 threadsRef <- envJobThreadsRef <$> getRunnerEnv
388395 jobRunner_ <- getJobRunner
389396
390397 a <- async $ liftIO $ jobRunner_ job
391398
392− _x <- atomicModifyIORef' threadsRef $ \threads -> (a:threads, DL.map asyncThreadId (a:threads))
399+ _x <- atomicModifyIORef' threadsRef $ \threads ->
400+ ( DM.insert jobId a threads
401+ , DL.map asyncThreadId $ DM.elems $ DM.insert jobId a threads
402+ )
403+
393404 -- liftIO $ putStrLn $ "Threads: " <> show x
394405 log LevelDebug $ LogText $ toS $ "Spawned job in " <> show (asyncThreadId a)
395406
@@ -399,7 +410,7 @@ runJobWithTimeout timeoutSec job = do
399410
400411 void $ finally
401412 (waitEitherCancel a t)
402− (atomicModifyIORef' threadsRef $ \threads -> (DL.delete a threads, ()))
413+ (atomicModifyIORef' threadsRef $ \threads -> (DM.delete jobId threads, ()))
403414
404415
405416-- | runs a job, blocks for as long as it's in progress
@@ -455,6 +466,25 @@ runJob jid = do
455466
456467 pure ()
457468
469+killJob :: (HasJobRunner m) => JobId -> m ()
470+killJob jid = do
471+ threadsRef <- envJobThreadsRef <$> getRunnerEnv
472+ threads <- liftIO $ readIORef threadsRef
473+ mJob <- findJobById jid
474+
475+ case (mJob, jid `DM.lookup` threads) of
476+ (Just job, Just thread) -> do
477+ log LevelInfo $ LogKillJobSuccess job
478+ void $ finally
479+ (uninterruptibleCancel thread)
480+ (atomicModifyIORef' threadsRef $ \threads' -> (DM.delete jid threads', ()))
481+
482+ (Just job, Nothing) -> do
483+ log LevelInfo $ LogKillJobFailed job
484+
485+ (Nothing, _) ->
486+ log LevelError $ LogText $ "Unable to find job in db to kill, jobId = " <> toS (show jid)
487+
458488-- TODO: This might have a resource leak.
459489restartUponCrash :: (HasJobRunner m, Show a) => Text -> m a -> m ()
460490restartUponCrash name_ action = do
@@ -480,9 +510,11 @@ jobMonitor = do
480510 traceM "jobMonitor top"
481511 a1 <- async $ restartUponCrash "Job poller" jobPoller
482512 a2 <- async $ restartUponCrash "Job event listener" jobEventListener
513+ a3 <- async $ restartUponCrash "Job Kill poller" killJobPoller
483514 traceM "jobMonitor after async"
484− finally (void $ waitAnyCatch [a1, a2]) $ do
515+ finally (void $ waitAnyCatch [a1, a2, a3]) $ do
485516 log LevelInfo (LogText "Stopping jobPoller and jobEventListener threads.")
517+ cancel a3
486518 cancel a2
487519 cancel a1
488520 log LevelInfo (LogText "Waiting for jobs to complete.")
@@ -503,11 +535,19 @@ jobPollingWithResourceSql =
503535 \ ORDER BY attempts ASC, run_at ASC LIMIT 1) \
504536 \ RETURNING id"
505537
538+-- | Ref: 'killJobPoller'
539+killJobPollingSql :: Query
540+killJobPollingSql =
541+ "UPDATE ? SET locked_at = NULL, locked_by = NULL \
542+ \ WHERE id IN (SELECT id FROM ? WHERE status = ? AND locked_by = ? AND locked_at <= ? \
543+ \ ORDER BY locked_at ASC LIMIT 1 FOR UPDATE \
544+ \ ) RETURNING id"
545+
506546waitForJobs :: (HasJobRunner m)
507547 => m ()
508548waitForJobs = do
509− threadsRef <- envJobThreadsRef <$> getRunnerEnv
510− readIORef threadsRef >>= \case
549+ curJobs <- getRunnerEnv >>= (readIORef . envJobThreadsRef) <&> DM.elems
550+ case curJobs of
511551 [] -> log LevelInfo $ LogText "All job-threads exited"
512552 as -> do
513553 tid <- myThreadId
@@ -526,7 +566,7 @@ getConcurrencyControlFn :: (HasJobRunner m)
526566getConcurrencyControlFn = getConcurrencyControl >>= \case
527567 UnlimitedConcurrentJobs -> pure $ const $ pure PollAny
528568 MaxConcurrentJobs maxJobs -> pure $ const $ do
529− curJobs <- getRunnerEnv >>= (readIORef . envJobThreadsRef)
569+ curJobs <- getRunnerEnv >>= (readIORef . envJobThreadsRef) <&> DM.elems
530570 pure $ pollIf $ DL.length curJobs < maxJobs
531571 ResourceLimits resCfg -> pure $ const $ pure $ PollWithResources resCfg
532572 DynamicConcurrency fn -> pure $ const $ pollIf <$> liftIO fn
@@ -630,6 +670,43 @@ pollRunJob processName mResCfg = do
630670 delayAction = delaySeconds =<< getPollingInterval
631671 noDelayAction = pure ()
632672
673+-- | Executes 'killJobPollingSql' every 'cfgPollingInterval' seconds to pick up jobs
674+-- that are cancelled and need to be killed. Uses @UPDATE@ along with @SELECT...
675+-- ..FOR UPDATE@ to efficiently find a job that matches /all/ of the following
676+-- conditions:
677+--
678+-- * 'jobStatus' should be 'cancelled'
679+-- * 'jobLockedAt' should be in the past
680+-- * 'jobLockedBy' should be the current job worker name
681+
682+killJobPoller :: (HasJobRunner m) => m ()
683+killJobPoller = do
684+ processName <- liftIO jobWorkerName
685+ pool <- getDbPool
686+ tname <- getTableName
687+
688+ let pollJobToKill conn = join $ mask_ $ do
689+ currentTime <- liftIO getCurrentTime
690+ result <- liftIO $ PGS.query conn killJobPollingSql
691+ (tname, tname, Cancelled, processName, currentTime)
692+
693+ case result of
694+ [] ->
695+ pure delayAction
696+
697+ [Only (jobId :: JobId)] -> do
698+ void $ async $ killJob jobId
699+ pure noDelayAction
700+
701+ x ->
702+ error $ "I was supposed to get only a single row, but got: " ++ show x
703+
704+ withResource pool (forever . pollJobToKill)
705+
706+ where
707+ delayAction = delaySeconds =<< getPollingInterval
708+ noDelayAction = pure ()
709+
633710-- | Uses PostgreSQL's LISTEN/NOTIFY to be immediately notified of newly created
634711-- jobs.
635712jobEventListener :: (HasJobRunner m)
src/OddJobs/Migrations.hs+3−1
@@ -22,7 +22,9 @@ createJobTableQuery = "CREATE TABLE IF NOT EXISTS ?" <>
2222 ", attempts int not null default 0" <>
2323 ", locked_at timestamp with time zone null" <>
2424 ", locked_by text null" <>
25− ", constraint incorrect_locking_info CHECK ((status <> 'locked' and locked_at is null and locked_by is null) or (status = 'locked' and locked_at is not null and locked_by is not null))" <>
25+ ", constraint incorrect_locking_info CHECK (" <>
26+ "(locked_at is null and locked_by is null and status <> 'locked') or " <>
27+ "(locked_at is not null and locked_by is not null and (status = 'locked' or status = 'cancelled')))" <>
2628 ");" <>
2729 "create index if not exists ? on ?(created_at);" <>
2830 "create index if not exists ? on ?(updated_at);" <>
src/OddJobs/Types.hs+12−2
@@ -35,7 +35,7 @@ import Control.Monad.Logger (LogLevel)
3535-- myJobsTable :: TableName
3636-- myJobsTable = "my_jobs"
3737-- @
38−--
38+--
3939-- This should also work for table names qualified by the schema name. For example:
4040--
4141-- @
@@ -72,6 +72,11 @@ data LogEvent
7272 | LogJobFailed !Job !SomeException !FailureMode !NominalDiffTime
7373 -- | Emitted when a job times out and is picked-up again for execution
7474 | LogJobTimeout !Job
75+ -- | Emitted when user kills a job and the job thread sucessfully cancelled thereafter.
76+ | LogKillJobSuccess !Job
77+ -- | Emitted when user kills a job and the job thread is not found in the threadRef
78+ -- | (most likely the job has either got completed or timed out).
79+ | LogKillJobFailed !Job
7580 -- | Emitted whenever 'OddJobs.Job.jobPoller' polls the DB table
7681 | LogPoll
7782 -- | TODO
@@ -182,8 +187,11 @@ data Status
182187 -- | Jobs in 'Queued' status /may/ be picked up by the job-runner on the basis
183188 -- of the 'jobRunAt' field.
184189 | Queued
185− -- | Jobs in 'Failed' status will will not be retried by the job-runner.
190+ -- | Jobs in 'Failed' status will not be retried by the job-runner.
186191 | Failed
192+ -- | Jobs with 'Cancelled' status are cancelled by the user and will not be
193+ -- retried by the job-runner
194+ | Cancelled
187195 -- | Jobs in 'Retry' status will be retried by the job-runner on the basis of
188196 -- the 'jobRunAt' field.
189197 | Retry
@@ -227,6 +235,7 @@ instance ToText Status where
227235 Queued -> "queued"
228236 Retry -> "retry"
229237 Failed -> "failed"
238+ Cancelled -> "cancelled"
230239 Locked -> "locked"
231240
232241instance (StringConv Text a) => FromText (Either a Status) where
@@ -234,6 +243,7 @@ instance (StringConv Text a) => FromText (Either a Status) where
234243 "success" -> Right Success
235244 "queued" -> Right Queued
236245 "failed" -> Right Failed
246+ "cancelled" -> Right Cancelled
237247 "retry" -> Right Retry
238248 "locked" -> Right Locked
239249 x -> Left $ toS $ "Unknown job status: " <> x
src/OddJobs/Web.hs+25−1
@@ -116,6 +116,7 @@ instance ToHttpApiData Filter where
116116-- , rEnqueue :: route :- "enqueue" :> Capture "jobId" JobId :> Post '[HTML] NoContent
117117-- , rRunNow :: route :- "run" :> Capture "jobId" JobId :> Post '[HTML] NoContent
118118-- , rCancel :: route :- "cancel" :> Capture "jobId" JobId :> Post '[HTML] NoContent
119+-- , rKill :: route :- "kill" :> Capture "jobId" JobId :> Post '[HTML] NoContent
119120-- , rRefreshJobTypes :: route :- "refresh-job-types" :> Post '[HTML] NoContent
120121-- , rRefreshJobRunners :: route :- "refresh-job-runners" :> Post '[HTML] NoContent
121122-- } deriving (Generic)
@@ -126,6 +127,7 @@ data Routes = Routes
126127 , rEnqueue :: JobId -> Text
127128 , rRunNow :: JobId -> Text
128129 , rCancel :: JobId -> Text
130+ , rKill :: JobId -> Text
129131 , rRefreshJobTypes :: Text
130132 , rRefreshJobRunners :: Text
131133 , rStaticAsset :: Text -> Text
@@ -382,6 +384,7 @@ jobRow routes t (job@Job{..}, jobHtml) = do
382384 let statusFn = case jobStatus of
383385 Job.Success -> statusSuccess
384386 Job.Failed -> statusFailed
387+ Job.Cancelled -> statusCancelled
385388 Job.Queued -> if jobRunAt > t
386389 then statusFuture
387390 else statusWaiting
@@ -394,11 +397,12 @@ jobRow routes t (job@Job{..}, jobHtml) = do
394397 let actionsFn = case jobStatus of
395398 Job.Success -> const mempty
396399 Job.Failed -> actionsFailed
400+ Job.Cancelled -> actionsCancelled
397401 Job.Queued -> if jobRunAt > t
398402 then actionsFuture
399403 else actionsWaiting
400404 Job.Retry -> actionsRetry
401− Job.Locked -> const mempty
405+ Job.Locked -> actionsLocked
402406 actionsFn routes job
403407
404408
@@ -407,6 +411,15 @@ actionsFailed Routes{..} Job{..} = do
407411 form_ [ action_ (rEnqueue jobId), method_ "post" ] $ do
408412 button_ [ class_ "btn btn-secondary", type_ "submit" ] "Enqueue again"
409413
414+actionsCancelled :: Routes -> Job -> Html ()
415+actionsCancelled Routes{..} Job{..} = case (jobLockedAt, jobLockedBy) of
416+ (Just _, Just _) -> do
417+ span_ [ class_ "badge badge-light" ] "Killing job"
418+
419+ _ -> do
420+ form_ [ action_ (rEnqueue jobId), method_ "post" ] $ do
421+ button_ [ class_ "btn btn-secondary", type_ "submit" ] "Enqueue again"
422+
410423actionsRetry :: Routes -> Job -> Html ()
411424actionsRetry Routes{..} Job{..} = do
412425 form_ [ action_ (rRunNow jobId), method_ "post" ] $ do
@@ -422,6 +435,11 @@ actionsWaiting Routes{..} Job{..} = do
422435 form_ [ action_ (rCancel jobId), method_ "post" ] $ do
423436 button_ [ class_ "btn btn-danger", type_ "submit" ] "Cancel"
424437
438+actionsLocked :: Routes -> Job -> Html ()
439+actionsLocked Routes{..} Job{..} = do
440+ form_ [ action_ (rKill jobId), method_ "post" ] $ do
441+ button_ [ class_ "btn btn-warning", type_ "submit" ] "Kill"
442+
425443statusSuccess :: UTCTime -> Job -> Html ()
426444statusSuccess t Job{..} = do
427445 span_ [ class_ "badge badge-success" ] "Success"
@@ -436,6 +454,12 @@ statusFailed t Job{..} = do
436454 span_ [ class_ "job-run-time" ] $ do
437455 abbr_ [ title_ (showText jobUpdatedAt) ] $ toHtml $ "Failed " <> humanReadableTime' t jobUpdatedAt <> " after " <> show jobAttempts <> " attempts"
438456
457+statusCancelled :: UTCTime -> Job -> Html ()
458+statusCancelled t Job{..} = do
459+ span_ [ class_ "badge badge-danger" ] "Cancelled"
460+ span_ [ class_ "job-run-time" ] $ do
461+ abbr_ [ title_ (showText jobUpdatedAt) ] $ toHtml $ "Cancelled " <> humanReadableTime' t jobUpdatedAt
462+
439463statusFuture :: UTCTime -> Job -> Html ()
440464statusFuture t Job{..} = do
441465 span_ [ class_ "badge badge-secondary" ] "Future"
test/Test.hs+24
@@ -92,6 +92,7 @@ tests appPool jobPool = testGroup "All tests"
9292 , testOnJobTimeout appPool jobPool
9393 ]
9494 , testResourceLimitedScheduling appPool jobPool
95+ , testKillJob appPool jobPool
9596 ]
9697 -- , testGroup "property tests" [ testEverything appPool jobPool
9798 -- -- , propFilterJobs appPool jobPool
@@ -166,6 +167,8 @@ logEventToJob le = case le of
166167 Job.LogJobSuccess j _ -> Just j
167168 Job.LogJobFailed j _ _ _ -> Just j
168169 Job.LogJobTimeout j -> Just j
170+ Job.LogKillJobSuccess j -> Just j
171+ Job.LogKillJobFailed j -> Just j
169172 Job.LogPoll -> Nothing
170173 Job.LogWebUIRequest -> Nothing
171174 Job.LogText _ -> Nothing
@@ -195,6 +198,8 @@ assertJobIdStatus conn tname logRef msg st jid = do
195198 Job.LogJobSuccess j _ -> jid == Job.jobId j
196199 Job.LogJobFailed j _ _ _ -> jid == Job.jobId j
197200 Job.LogJobTimeout j -> jid == Job.jobId j
201+ Job.LogKillJobSuccess _ -> False
202+ Job.LogKillJobFailed _ -> False
198203 Job.LogWebUIRequest -> False
199204 Job.LogText _ -> False
200205 Job.LogPoll -> False
@@ -205,6 +210,12 @@ assertJobIdStatus conn tname logRef msg st jid = do
205210 Job.LogJobFailed j _ _ _ -> jid == Job.jobId j
206211 _ -> False
207212
213+ Job.Cancelled ->
214+ assertBool (msg <> ": Job killed event not found in job-logs for JobId=" <> show jid) $
215+ flip DL.any logs $ \case
216+ Job.LogKillJobSuccess j -> jid == Job.jobId j
217+ _ -> False
218+
208219 Job.Retry ->
209220 assertBool (msg <> ": Failed event not found in job-logs for JobId=" <> show jid) $
210221 flip DL.any logs $ \case
@@ -627,6 +638,19 @@ setup' jobPool defaultLimit action =
627638 , Job.cfgDeleteSuccessfulJobs = False
628639 }
629640
641+testKillJob appPool jobPool = testCase "killing a ongoing job" $ do
642+ withRandomTable jobPool $ \tname -> withNamedJobMonitor tname jobPool Prelude.id $ \logRef -> do
643+ Pool.withResource appPool $ \conn -> do
644+ Job{jobId = jid} <- Job.createJob conn tname (PayloadSucceed 60)
645+ delaySeconds $ Job.defaultPollingInterval + Seconds 2
646+
647+ assertJobIdStatus conn tname logRef "Job should be running" Job.Locked jid
648+
649+ _ <- Job.killJobIO conn tname jid
650+ delaySeconds $ Job.defaultPollingInterval + Seconds 2
651+
652+ assertJobIdStatus conn tname logRef "Job is cancelled and the job thread should be killed" Job.Cancelled jid
653+
630654data JobEvent = JobStart
631655 | JobRetry
632656 | JobSuccess