@@ -1,4 +1,5 @@
11{-# LANGUAGE RankNTypes, FlexibleInstances, FlexibleContexts, PartialTypeSignatures, UndecidableInstances #-}
2+{-# LANGUAGE ExistentialQuantification, RecordWildCards, ScopedTypeVariables #-}
23
34module OddJobs.Job
45 (
@@ -12,6 +13,7 @@ module OddJobs.Job
1213 -- $config
1314 , Config(..)
1415 , ConcurrencyControl(..)
16+ , ResourceCfg(..)
1517
1618 -- * Creating/scheduling jobs
1719 --
@@ -19,6 +21,9 @@ module OddJobs.Job
1921 , createJob
2022 , scheduleJob
2123
24+ , createJobWithResources
25+ , scheduleJobWithResources
26+
2227 -- * @Job@ and associated data-types
2328 --
2429 -- $dataTypes
@@ -31,6 +36,8 @@ module OddJobs.Job
3136 , Seconds(..)
3237 , JobErrHandler(..)
3338 , AllJobTypes(..)
39+ , ResourceId(..)
40+ , FunctionName
3441
3542 -- ** Structured logging
3643 --
@@ -78,7 +85,7 @@ import Data.Pool(Pool)
7885import Data.Text as T
7986import Database.PostgreSQL.Simple as PGS
8087import Database.PostgreSQL.Simple.Notification
81−import UnliftIO.Async
88+import UnliftIO.Async hiding (poll)
8289import UnliftIO.Concurrent (threadDelay, myThreadId)
8390import Data.String
8491import System.Posix.Process (getProcessID)
@@ -89,7 +96,8 @@ import Control.Monad.Logger as MLogger (LogLevel(..), LogStr, toLogStr)
8996import UnliftIO.IORef
9097import UnliftIO.Exception ( SomeException(..), try, catch, finally
9198 , catchAny, bracket, Exception(..), throwIO
92− , catches, Handler(..), mask_, throwString
99+ , catches, Handler(..), mask_, onException
100+ , throwString
93101 )
94102import Data.Proxy
95103import Control.Monad.Trans.Control
@@ -309,7 +317,7 @@ saveJob j = do
309317saveJobIO :: Connection -> TableName -> Job -> IO Job
310318saveJobIO conn tname Job{jobRunAt, jobStatus, jobPayload, jobLastError, jobAttempts, jobLockedBy, jobLockedAt, jobId} = do
311319 rs <- PGS.query conn saveJobQuery
312− ( tname
320+ ( tname
313321 , jobRunAt
314322 , jobStatus
315323 , jobPayload
@@ -476,7 +484,18 @@ jobMonitor = do
476484
477485-- | Ref: 'jobPoller'
478486jobPollingSql :: Query
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"
487+jobPollingSql =
488+ "update ? set status = ?, locked_at = ?, locked_by = ?, attempts=attempts+1\
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"
491+
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<?))) \
496+ \ AND ?(id) \
497+ \ ORDER BY attempts ASC, run_at ASC LIMIT 1) \
498+ \ RETURNING id"
480499
481500waitForJobs :: (HasJobRunner m)
482501 => m ()
@@ -491,15 +510,23 @@ waitForJobs = do
491510 delaySeconds (Seconds 1)
492511 waitForJobs
493512
513+data ConcurrencyAction
514+ = DontPoll
515+ | PollAny
516+ | PollWithResources ResourceCfg
517+
494518getConcurrencyControlFn :: (HasJobRunner m)
495− => m (m Bool)
519+ => m (Connection -> m ConcurrencyAction)
496520getConcurrencyControlFn = getConcurrencyControl >>= \case
497− UnlimitedConcurrentJobs -> pure $ pure True
498− MaxConcurrentJobs maxJobs -> pure $ do
521+ UnlimitedConcurrentJobs -> pure $ const $ pure PollAny
522+ MaxConcurrentJobs maxJobs -> pure $ const $ do
499523 curJobs <- getRunnerEnv >>= (readIORef . envJobThreadsRef)
500− pure $ DL.length curJobs < maxJobs
501− DynamicConcurrency fn -> pure $ liftIO fn
524+ pure $ pollIf $ DL.length curJobs < maxJobs
525+ ResourceLimits resCfg -> pure $ const $ pure $ PollWithResources resCfg
526+ DynamicConcurrency fn -> pure $ const $ pollIf <$> liftIO fn
502527
528+ where
529+ pollIf cond = if cond then PollAny else DontPoll
503530
504531jobPollingIO :: Connection -> String -> TableName -> Seconds -> IO [Only JobId]
505532jobPollingIO pollerDbConn processName tname lockTimeout = do
@@ -537,27 +564,55 @@ jobPoller = do
537564 lockTimeout <- getDefaultJobTimeout
538565 traceM "jobPoller just before log statement"
539566 log LevelInfo $ LogText $ toS $ "Starting the job monitor via DB polling with processName=" <> processName
567+
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
572+ Nothing -> liftIO $
573+ PGS.query conn jobPollingSql
574+ ( tname
575+ , Locked
576+ , t
577+ , processName
578+ , tname
579+ , t
580+ , In [Queued, Retry]
581+ , Locked
582+ , addUTCTime (fromIntegral $ negate $ unSeconds lockTimeout) t)
583+ Just ResourceCfg{..} -> liftIO $
584+ PGS.query conn jobPollingWithResourceSql
585+ ( tname
586+ , Locked
587+ , t
588+ , processName
589+ , tname
590+ , t
591+ , In [Queued, Retry]
592+ , Locked
593+ , addUTCTime (fromIntegral $ negate $ unSeconds lockTimeout) t
594+ , resCfgCheckResourceFunction
595+ )
596+ case r of
597+ -- When we don't have any jobs to run, we can relax a bit...
598+ [] -> pure delayAction
599+
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
603+ pure noDelayAction
604+
605+ x -> error $ "WTF just happened? I was supposed to get only a single row, but got: " ++ show x
606+
540607 concurrencyControlFn <- getConcurrencyControlFn
541− withResource pool $ \pollerDbConn -> forever $ concurrencyControlFn >>= \case
542− False -> do
608+ withResource pool $ \pollerDbConn -> forever $ concurrencyControlFn pollerDbConn >>= \case
609+ DontPoll -> do
543610 log LevelWarn $ LogText "NOT polling the job queue due to concurrency control"
544611 -- If we can't run any jobs ATM, relax and wait for resources to free up
545612 delayAction
546− True -> do
547− join
548− (mask_ $ do
549− log LevelDebug $ LogText $ toS $ "[" <> processName <> "] Polling the job queue.."
550− r <- liftIO $ jobPollingIO pollerDbConn processName tname lockTimeout
551− case r of
552− -- When we don't have any jobs to run, we can relax a bit...
553− [] -> pure delayAction
554−
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
558− pure noDelayAction
559−
560− x -> error $ "WTF just happened? I was supposed to get only a single row, but got: " ++ show x)
613+ PollAny -> poll pollerDbConn Nothing
614+ PollWithResources resCfg -> poll pollerDbConn (Just resCfg)
615+
561616 where
562617 delayAction = delaySeconds =<< getPollingInterval
563618 noDelayAction = pure ()
@@ -573,9 +628,17 @@ jobEventListener = do
573628 jwName <- liftIO jobWorkerName
574629 concurrencyControlFn <- getConcurrencyControlFn
575630
576− let tryLockingJob jid = do
631+ let tryLockingJob jid mResCfg = withDbConnection $ \conn -> do
577632 let q = "UPDATE ? SET status=?, locked_at=now(), locked_by=?, attempts=attempts+1 WHERE id=? AND status in ? RETURNING id"
578− withDbConnection (\conn -> liftIO $ PGS.query conn q (tname, Locked, jwName, jid, In [Queued, Retry])) >>= \case
633+ qWithResources =
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
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)
640+
641+ case result of
579642 [] -> do
580643 log LevelDebug $ LogText $ toS $ "Job was locked by someone else before I could start. Skipping it. JobId=" <> show jid
581644 pure Nothing
@@ -587,29 +650,34 @@ jobEventListener = do
587650 forever $ do
588651 log LevelDebug $ LogText "[LISTEN/NOTIFY] Event loop"
589652 notif <- liftIO $ getNotification monitorDbConn
590− concurrencyControlFn >>= \case
591− False -> log LevelWarn $ LogText "Received job event, but ignoring it due to concurrency control"
592− True -> do
593− let pload = notificationData notif
594− log LevelDebug $ LogText $ toS $ "NOTIFY | " <> show pload
595− 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
597−
598− -- 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
600− 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
602− Just (jid, runAt_, mLockedAt_) -> do
603− t <- liftIO getCurrentTime
604− 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
606− void $ async $ do
607− -- 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.
609− tryLockingJob jid >>= \case
610− Nothing -> pure ()
611− Just lockedJid -> runJob lockedJid
612− else log LevelDebug $ LogText $ toS $ "Job is either for future, or is already locked. Skipping. JobId=" <> show jid
653+
654+ let pload = notificationData notif
655+ runNotifWithFilter :: HasJobRunner m => Maybe ResourceCfg -> m ()
656+ runNotifWithFilter mResCfg = do
657+ log LevelDebug $ LogText $ toS $ "NOTIFY | " <> show pload
658+ case eitherDecode $ toS pload of
659+ Left e -> log LevelError $ LogText $ toS $ "Unable to decode notification payload received from Postgres. Payload=" <> show pload <> " Error=" <> show e
660+
661+ -- Checking if job needs to be fired immediately AND it is not already
662+ -- taken by some othe thread, by the time it got to us
663+ Right (v :: Value) -> case Aeson.parseMaybe parser v of
664+ Nothing -> log LevelError $ LogText $ toS $ "Unable to extract id/run_at/locked_at from " <> show pload
665+ Just (jid, runAt_, mLockedAt_) -> do
666+ t <- liftIO getCurrentTime
667+ if (runAt_ <= t) && isNothing mLockedAt_
668+ then do log LevelDebug $ LogText $ toS $ "Job needs needs to be run immediately. Attempting to fork in background. JobId=" <> show jid
669+ void $ async $ do
670+ -- Let's try to lock the job first... it is possible that it has already
671+ -- been picked up by the poller by the time we get here.
672+ tryLockingJob jid mResCfg >>= \case
673+ Nothing -> pure ()
674+ Just lockedJid -> runJob lockedJid
675+ else log LevelDebug $ LogText $ toS $ "Job is either for future, is already locked, or would violate concurrency constraints. Skipping. JobId=" <> show jid
676+
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)
613681 where
614682 parser :: Value -> Aeson.Parser (JobId, UTCTime, Maybe UTCTime)
615683 parser = withObject "expecting an object to parse job.run_at and job.locked_at" $ \o -> do
@@ -623,6 +691,12 @@ jobEventListener = do
623691createJobQuery :: PGS.Query
624692createJobQuery = "INSERT INTO ? (run_at, status, payload, last_error, attempts, locked_at, locked_by) VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING " <> concatJobDbColumns
625693
694+ensureResource :: PGS.Query
695+ensureResource = "INSERT INTO ? (id, usage_limit) VALUES (?, ?) ON CONFLICT DO NOTHING"
696+
697+registerResourceUsage :: PGS.Query
698+registerResourceUsage = "INSERT INTO ? (job_id, resource_id, usage) VALUES (?, ?, ?)"
699+
626700-- $createJobs
627701--
628702-- Ideally you'd want to create wrappers for 'createJob' and 'scheduleJob' in
@@ -668,6 +742,50 @@ scheduleJob conn tname payload runAt = do
668742 [r] -> pure r
669743 _ -> Prelude.error . (<> "Not expecting multiple rows when creating a single job. Query=") <$> queryFormatter
670744
745+type ResourceList = [(ResourceId, Int)]
746+
747+createJobWithResources
748+ :: ToJSON p
749+ => Connection
750+ -> TableName
751+ -> ResourceCfg
752+ -> p
753+ -> ResourceList
754+ -> IO Job
755+createJobWithResources conn tname resCfg payload resources = do
756+ t <- getCurrentTime
757+ scheduleJobWithResources conn tname resCfg payload resources t
758+
759+scheduleJobWithResources
760+ :: ToJSON p
761+ => Connection
762+ -> TableName
763+ -> ResourceCfg
764+ -> p
765+ -> ResourceList
766+ -> UTCTime
767+ -> IO Job
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.
771+ PGS.begin conn
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
775+
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
778+ [r] -> pure r
779+ _ -> Prelude.error . (<> "Not expecting multiple rows when creating a single job. Query=") <$> queryFormatter
780+
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)
784+
785+ PGS.commit conn
786+
787+ pure job
788+
671789
672790-- getRunnerEnv :: (HasJobRunner m) => m RunnerEnv
673791-- getRunnerEnv = ask