@@ -1,4 +1,5 @@
1{-# LANGUAGE RankNTypes, FlexibleInstances, FlexibleContexts, PartialTypeSignatures, UndecidableInstances #-}
1{-# LANGUAGE RankNTypes, FlexibleInstances, FlexibleContexts, PartialTypeSignatures, UndecidableInstances #-}
2+{-# LANGUAGE ExistentialQuantification, RecordWildCards, ScopedTypeVariables #-}
3module OddJobs.Job
4module OddJobs.Job
@@ -12,6 +13,7 @@ module OddJobs.Job
12 -- $config
13 -- $config
13 , Config(..)
14 , Config(..)
14 , ConcurrencyControl(..)
15 , ConcurrencyControl(..)
16 -- * Creating/scheduling jobs
18 -- * Creating/scheduling jobs
@@ -19,6 +21,9 @@ module OddJobs.Job
19 , createJob
21 , createJob
20 , scheduleJob
22 , scheduleJob
24+ , createJobWithResources
25+ , scheduleJobWithResources
22 -- * @Job@ and associated data-types
27 -- * @Job@ and associated data-types
24 -- $dataTypes
29 -- $dataTypes
@@ -31,6 +36,8 @@ module OddJobs.Job
31 , Seconds(..)
36 , Seconds(..)
32 , JobErrHandler(..)
37 , JobErrHandler(..)
33 , AllJobTypes(..)
38 , AllJobTypes(..)
35 -- ** Structured logging
42 -- ** Structured logging
@@ -78,7 +85,7 @@ import Data.Pool(Pool)
78import Data.Text as T
85import Data.Text as T
79import Database.PostgreSQL.Simple as PGS
86import Database.PostgreSQL.Simple as PGS
80import Database.PostgreSQL.Simple.Notification
87import Database.PostgreSQL.Simple.Notification
81−import UnliftIO.Async
88+import UnliftIO.Async hiding (poll)
82import UnliftIO.Concurrent (threadDelay, myThreadId)
89import UnliftIO.Concurrent (threadDelay, myThreadId)
83import Data.String
90import Data.String
84import System.Posix.Process (getProcessID)
91import System.Posix.Process (getProcessID)
@@ -89,7 +96,8 @@ import Control.Monad.Logger as MLogger (LogLevel(..), LogStr, toLogStr)
89import UnliftIO.IORef
96import UnliftIO.IORef
90import UnliftIO.Exception ( SomeException(..), try, catch, finally
97import UnliftIO.Exception ( SomeException(..), try, catch, finally
91 , catchAny, bracket, Exception(..), throwIO
98 , catchAny, bracket, Exception(..), throwIO
92− , catches, Handler(..), mask_, throwString
99+ , catches, Handler(..), mask_, onException
94import Data.Proxy
102import Data.Proxy
95import Control.Monad.Trans.Control
103import Control.Monad.Trans.Control
@@ -309,7 +317,7 @@ saveJob j = do
309saveJobIO :: Connection -> TableName -> Job -> IO Job
317saveJobIO :: Connection -> TableName -> Job -> IO Job
310saveJobIO conn tname Job{jobRunAt, jobStatus, jobPayload, jobLastError, jobAttempts, jobLockedBy, jobLockedAt, jobId} = do
318saveJobIO conn tname Job{jobRunAt, jobStatus, jobPayload, jobLastError, jobAttempts, jobLockedBy, jobLockedAt, jobId} = do
311 rs <- PGS.query conn saveJobQuery
319 rs <- PGS.query conn saveJobQuery
312− ( tname
320+ ( tname
313 , jobRunAt
321 , jobRunAt
314 , jobStatus
322 , jobStatus
315 , jobPayload
323 , jobPayload
@@ -476,7 +484,18 @@ jobMonitor = do
477-- | Ref: 'jobPoller'
485-- | Ref: 'jobPoller'
478jobPollingSql :: Query
486jobPollingSql :: Query
488+ "update ? set status = ?, locked_at = ?, locked_by = ?, attempts=attempts+1\
479−jobPollingSql = "update ? set status = ?, locked_at = ?, locked_by = ?, attempts=attempts+1 WHERE id in (select id from ? where (run_at<=? AND ((status in ?) OR (status = ? and locked_at<?))) ORDER BY attempts ASC, run_at ASC LIMIT 1 FOR UPDATE) RETURNING id"
489+ \ WHERE id in (select id from ? where (run_at<=? AND ((status in ?) OR (status = ? and locked_at<?))) \
490+ \ ORDER BY attempts ASC, run_at ASC LIMIT 1 FOR UPDATE) RETURNING id"
492+jobPollingWithResourceSql :: Query
493+jobPollingWithResourceSql =
494+ " UPDATE ? SET status = ?, locked_at = ?, locked_by = ?, attempts = attempts + 1 \
495+ \ WHERE id in (select id from ? where (run_at<=? AND ((status in ?) OR (status = ? and locked_at<?))) \
497+ \ ORDER BY attempts ASC, run_at ASC LIMIT 1) \
481waitForJobs :: (HasJobRunner m)
500waitForJobs :: (HasJobRunner m)
@@ -491,15 +510,23 @@ waitForJobs = do
491 delaySeconds (Seconds 1)
510 delaySeconds (Seconds 1)
492 waitForJobs
511 waitForJobs
513+data ConcurrencyAction
516+ | PollWithResources ResourceCfg
494getConcurrencyControlFn :: (HasJobRunner m)
518getConcurrencyControlFn :: (HasJobRunner m)
495− => m (m Bool)
519+ => m (Connection -> m ConcurrencyAction)
496getConcurrencyControlFn = getConcurrencyControl >>= \case
520getConcurrencyControlFn = getConcurrencyControl >>= \case
497− UnlimitedConcurrentJobs -> pure $ pure True
521+ UnlimitedConcurrentJobs -> pure $ const $ pure PollAny
498− MaxConcurrentJobs maxJobs -> pure $ do
522+ MaxConcurrentJobs maxJobs -> pure $ const $ do
499 curJobs <- getRunnerEnv >>= (readIORef . envJobThreadsRef)
523 curJobs <- getRunnerEnv >>= (readIORef . envJobThreadsRef)
500− pure $ DL.length curJobs < maxJobs
524+ pure $ pollIf $ DL.length curJobs < maxJobs
525+ ResourceLimits resCfg -> pure $ const $ pure $ PollWithResources resCfg
501− DynamicConcurrency fn -> pure $ liftIO fn
526+ DynamicConcurrency fn -> pure $ const $ pollIf <$> liftIO fn
529+ pollIf cond = if cond then PollAny else DontPoll
504jobPollingIO :: Connection -> String -> TableName -> Seconds -> IO [Only JobId]
531jobPollingIO :: Connection -> String -> TableName -> Seconds -> IO [Only JobId]
505jobPollingIO pollerDbConn processName tname lockTimeout = do
532jobPollingIO pollerDbConn processName tname lockTimeout = do
@@ -537,27 +564,55 @@ jobPoller = do
537 lockTimeout <- getDefaultJobTimeout
564 lockTimeout <- getDefaultJobTimeout
538 traceM "jobPoller just before log statement"
565 traceM "jobPoller just before log statement"
539 log LevelInfo $ LogText $ toS $ "Starting the job monitor via DB polling with processName=" <> processName
566 log LevelInfo $ LogText $ toS $ "Starting the job monitor via DB polling with processName=" <> processName
568+ let poll conn mResCfg = join $ mask_ $ do
569+ log LevelDebug $ LogText $ toS $ "[" <> processName <> "] Polling the job queue.."
570+ t <- liftIO getCurrentTime
571+ r <- case mResCfg of
573+ PGS.query conn jobPollingSql
580+ , In [Queued, Retry]
582+ , addUTCTime (fromIntegral $ negate $ unSeconds lockTimeout) t)
583+ Just ResourceCfg{..} -> liftIO $
584+ PGS.query conn jobPollingWithResourceSql
591+ , In [Queued, Retry]
593+ , addUTCTime (fromIntegral $ negate $ unSeconds lockTimeout) t
594+ , resCfgCheckResourceFunction
597+ -- When we don't have any jobs to run, we can relax a bit...
598+ [] -> pure delayAction
600+ -- When we find a job to run, fork and try to find the next job without any delay...
601+ [Only (jid :: JobId)] -> do
602+ void $ async $ runJob jid
605+ x -> error $ "WTF just happened? I was supposed to get only a single row, but got: " ++ show x
540 concurrencyControlFn <- getConcurrencyControlFn
607 concurrencyControlFn <- getConcurrencyControlFn
541− withResource pool $ \pollerDbConn -> forever $ concurrencyControlFn >>= \case
608+ withResource pool $ \pollerDbConn -> forever $ concurrencyControlFn pollerDbConn >>= \case
542− False -> do
609+ DontPoll -> do
543 log LevelWarn $ LogText "NOT polling the job queue due to concurrency control"
610 log LevelWarn $ LogText "NOT polling the job queue due to concurrency control"
544 -- If we can't run any jobs ATM, relax and wait for resources to free up
611 -- If we can't run any jobs ATM, relax and wait for resources to free up
545 delayAction
612 delayAction
546− True -> do
613+ PollAny -> poll pollerDbConn Nothing
547− join
614+ PollWithResources resCfg -> poll pollerDbConn (Just resCfg)
549− log LevelDebug $ LogText $ toS $ "[" <> processName <> "] Polling the job queue.."
550− r <- liftIO $ jobPollingIO pollerDbConn processName tname lockTimeout
552− -- When we don't have any jobs to run, we can relax a bit...
553− [] -> pure delayAction
555− -- When we find a job to run, fork and try to find the next job without any delay...
556− [Only (jid :: JobId)] -> do
557− void $ async $ runJob jid
560− x -> error $ "WTF just happened? I was supposed to get only a single row, but got: " ++ show x)
562 delayAction = delaySeconds =<< getPollingInterval
617 delayAction = delaySeconds =<< getPollingInterval
563 noDelayAction = pure ()
618 noDelayAction = pure ()
@@ -573,9 +628,17 @@ jobEventListener = do
573 jwName <- liftIO jobWorkerName
628 jwName <- liftIO jobWorkerName
574 concurrencyControlFn <- getConcurrencyControlFn
629 concurrencyControlFn <- getConcurrencyControlFn
576− let tryLockingJob jid = do
631+ let tryLockingJob jid mResCfg = withDbConnection $ \conn -> do
577 let q = "UPDATE ? SET status=?, locked_at=now(), locked_by=?, attempts=attempts+1 WHERE id=? AND status in ? RETURNING id"
632 let q = "UPDATE ? SET status=?, locked_at=now(), locked_by=?, attempts=attempts+1 WHERE id=? AND status in ? RETURNING id"
634+ "UPDATE ? SET status=?, locked_at=now(), locked_by=?, attempts=attempts+1 \
635+ \ WHERE id=? AND status in ? AND ?(id) RETURNING id"
636+ result <- case mResCfg of
578− withDbConnection (\conn -> liftIO $ PGS.query conn q (tname, Locked, jwName, jid, In [Queued, Retry])) >>= \case
637+ Nothing -> liftIO $ PGS.query conn q (tname, Locked, jwName, jid, In [Queued, Retry])
638+ Just ResourceCfg{..} -> liftIO $ PGS.query conn qWithResources
639+ (tname, Locked, jwName, jid, In [Queued, Retry], resCfgCheckResourceFunction)
580 log LevelDebug $ LogText $ toS $ "Job was locked by someone else before I could start. Skipping it. JobId=" <> show jid
643 log LevelDebug $ LogText $ toS $ "Job was locked by someone else before I could start. Skipping it. JobId=" <> show jid
581 pure Nothing
644 pure Nothing
@@ -587,29 +650,34 @@ jobEventListener = do
587 forever $ do
650 forever $ do
588 log LevelDebug $ LogText "[LISTEN/NOTIFY] Event loop"
651 log LevelDebug $ LogText "[LISTEN/NOTIFY] Event loop"
589 notif <- liftIO $ getNotification monitorDbConn
652 notif <- liftIO $ getNotification monitorDbConn
590− concurrencyControlFn >>= \case
653+
591− False -> log LevelWarn $ LogText "Received job event, but ignoring it due to concurrency control"
593− let pload = notificationData notif
654+ let pload = notificationData notif
655+ runNotifWithFilter :: HasJobRunner m => Maybe ResourceCfg -> m ()
656+ runNotifWithFilter mResCfg = do
594− log LevelDebug $ LogText $ toS $ "NOTIFY | " <> show pload
657+ log LevelDebug $ LogText $ toS $ "NOTIFY | " <> show pload
595− case eitherDecode $ toS pload of
658+ case eitherDecode $ toS pload of
596− Left e -> log LevelError $ LogText $ toS $ "Unable to decode notification payload received from Postgres. Payload=" <> show pload <> " Error=" <> show e
659+ Left e -> log LevelError $ LogText $ toS $ "Unable to decode notification payload received from Postgres. Payload=" <> show pload <> " Error=" <> show e
598− -- Checking if job needs to be fired immediately AND it is not already
661+ -- Checking if job needs to be fired immediately AND it is not already
599− -- taken by some othe thread, by the time it got to us
662+ -- taken by some othe thread, by the time it got to us
600− Right (v :: Value) -> case Aeson.parseMaybe parser v of
663+ Right (v :: Value) -> case Aeson.parseMaybe parser v of
601− Nothing -> log LevelError $ LogText $ toS $ "Unable to extract id/run_at/locked_at from " <> show pload
664+ Nothing -> log LevelError $ LogText $ toS $ "Unable to extract id/run_at/locked_at from " <> show pload
602− Just (jid, runAt_, mLockedAt_) -> do
665+ Just (jid, runAt_, mLockedAt_) -> do
603− t <- liftIO getCurrentTime
666+ t <- liftIO getCurrentTime
604− if (runAt_ <= t) && isNothing mLockedAt_
667+ if (runAt_ <= t) && isNothing mLockedAt_
605− then do log LevelDebug $ LogText $ toS $ "Job needs needs to be run immediately. Attempting to fork in background. JobId=" <> show jid
668+ then do log LevelDebug $ LogText $ toS $ "Job needs needs to be run immediately. Attempting to fork in background. JobId=" <> show jid
606− void $ async $ do
669+ void $ async $ do
607− -- Let's try to lock the job first... it is possible that it has already
670+ -- Let's try to lock the job first... it is possible that it has already
608− -- been picked up by the poller by the time we get here.
671+ -- been picked up by the poller by the time we get here.
609− tryLockingJob jid >>= \case
672+ tryLockingJob jid mResCfg >>= \case
610− Nothing -> pure ()
673+ Nothing -> pure ()
611− Just lockedJid -> runJob lockedJid
674+ Just lockedJid -> runJob lockedJid
612− else log LevelDebug $ LogText $ toS $ "Job is either for future, or is already locked. Skipping. JobId=" <> show jid
675+ else log LevelDebug $ LogText $ toS $ "Job is either for future, is already locked, or would violate concurrency constraints. Skipping. JobId=" <> show jid
677+ concurrencyControlFn monitorDbConn >>= \case
678+ DontPoll -> log LevelWarn $ LogText "Received job event, but ignoring it due to concurrency control"
679+ PollAny -> runNotifWithFilter Nothing
680+ PollWithResources resCfg -> runNotifWithFilter (Just resCfg)
614 parser :: Value -> Aeson.Parser (JobId, UTCTime, Maybe UTCTime)
682 parser :: Value -> Aeson.Parser (JobId, UTCTime, Maybe UTCTime)
615 parser = withObject "expecting an object to parse job.run_at and job.locked_at" $ \o -> do
683 parser = withObject "expecting an object to parse job.run_at and job.locked_at" $ \o -> do
@@ -623,6 +691,12 @@ jobEventListener = do
623createJobQuery :: PGS.Query
691createJobQuery :: PGS.Query
624createJobQuery = "INSERT INTO ? (run_at, status, payload, last_error, attempts, locked_at, locked_by) VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING " <> concatJobDbColumns
692createJobQuery = "INSERT INTO ? (run_at, status, payload, last_error, attempts, locked_at, locked_by) VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING " <> concatJobDbColumns
694+ensureResource :: PGS.Query
695+ensureResource = "INSERT INTO ? (id, usage_limit) VALUES (?, ?) ON CONFLICT DO NOTHING"
697+registerResourceUsage :: PGS.Query
698+registerResourceUsage = "INSERT INTO ? (job_id, resource_id, usage) VALUES (?, ?, ?)"
626-- $createJobs
700-- $createJobs
628-- Ideally you'd want to create wrappers for 'createJob' and 'scheduleJob' in
702-- Ideally you'd want to create wrappers for 'createJob' and 'scheduleJob' in
@@ -668,6 +742,50 @@ scheduleJob conn tname payload runAt = do
668 [r] -> pure r
742 [r] -> pure r
669 _ -> Prelude.error . (<> "Not expecting multiple rows when creating a single job. Query=") <$> queryFormatter
743 _ -> Prelude.error . (<> "Not expecting multiple rows when creating a single job. Query=") <$> queryFormatter
745+type ResourceList = [(ResourceId, Int)]
747+createJobWithResources
755+createJobWithResources conn tname resCfg payload resources = do
757+ scheduleJobWithResources conn tname resCfg payload resources t
759+scheduleJobWithResources
768+scheduleJobWithResources conn tname ResourceCfg{..} payload resources runAt = do
769+ -- We insert everything in a single transaction to delay @NOTIFY@ calls,
770+ -- so a job isn't picked up before its resources are inserted.
772+ let args = ( tname, runAt, Queued, toJSON payload, Nothing :: Maybe Value, 0 :: Int, Nothing :: Maybe Text, Nothing :: Maybe Text )
773+ queryFormatter = toS <$> PGS.formatQuery conn createJobQuery args
774+ rs <- PGS.query conn createJobQuery args
776+ job <- flip onException (PGS.rollback conn) $ case rs of
777+ [] -> Prelude.error . (<> "Not expecting a blank result set when creating a job. Query=") <$> queryFormatter
779+ _ -> Prelude.error . (<> "Not expecting multiple rows when creating a single job. Query=") <$> queryFormatter
781+ forM_ resources $ \(resourceId, usage) -> do
782+ void $ PGS.execute conn ensureResource (resCfgResourceTable, rawResourceId resourceId, resCfgDefaultLimit)
783+ void $ PGS.execute conn registerResourceUsage (resCfgUsageTable, jobId job, rawResourceId resourceId, usage)
672-- getRunnerEnv :: (HasJobRunner m) => m RunnerEnv
790-- getRunnerEnv :: (HasJobRunner m) => m RunnerEnv
673-- getRunnerEnv = ask
791-- getRunnerEnv = ask