{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE FlexibleContexts #-}
module OddJobs.ConfigBuilder where
import OddJobs.Types
import Database.PostgreSQL.Simple as PGS
import Data.Pool
import Control.Monad.Logger (LogLevel(..), LogStr, toLogStr)
import Data.Text (Text)
import Lucid (Html, toHtml, class_, div_, span_, br_, button_, a_, href_, onclick_)
import Data.Maybe (fromMaybe)
import Data.List as DL
import Data.Aeson as Aeson hiding (Success)
import qualified Data.Text as T
import qualified Data.HashMap.Lazy as HM
import GHC.Generics
import Data.Proxy (Proxy(..))
import Generics.Deriving.ConNames
import Control.Monad
import Data.String.Conv
import GHC.Exts (toList)
import qualified Data.ByteString as BS
import UnliftIO (MonadUnliftIO, withRunInIO, bracket, liftIO)
import qualified System.Log.FastLogger as FLogger
mkConfig :: (LogLevel -> LogEvent -> IO ())
-> TableName
-> Pool Connection
-> ConcurrencyControl
-> (Job -> IO ())
-> (Config -> Config)
-> Config
mkConfig logger tname dbpool ccControl jrunner configOverridesFn =
let cfg = configOverridesFn $ Config
{ cfgPollingInterval = defaultPollingInterval
, cfgOnJobSuccess = (const $ pure ())
, cfgOnJobFailed = []
, cfgJobRunner = jrunner
, cfgLogger = logger
, cfgDbPool = dbpool
, cfgOnJobStart = (const $ pure ())
, cfgDefaultMaxAttempts = 10
, cfgTableName = tname
, cfgOnJobTimeout = (const $ pure ())
, cfgConcurrencyControl = ccControl
, cfgPidFile = Nothing
, cfgJobType = defaultJobType
, cfgDefaultJobTimeout = Seconds 600
, cfgJobToHtml = defaultJobToHtml (cfgJobType cfg)
, cfgAllJobTypes = (defaultDynamicJobTypes (cfgTableName cfg) (cfgJobTypeSql cfg))
, cfgJobTypeSql = defaultJobTypeSql
}
in cfg
defaultLogStr :: (Job -> Text)
-> LogLevel
-> LogEvent
-> LogStr
defaultLogStr jobTypeFn logLevel logEvent =
(toLogStr $ show logLevel) <> " | " <> str
where
jobToLogStr job@Job{jobId} =
"JobId=" <> (toLogStr $ show jobId) <> " JobType=" <> (toLogStr $ jobTypeFn job)
str = case logEvent of
LogJobStart j ->
"Started | " <> jobToLogStr j
LogJobFailed j e fm t ->
let tag = case fm of
FailWithRetry -> "Failed (retry)"
FailPermanent -> "Failed (permanent)"
in tag <> " | " <> jobToLogStr j <> " | runtime=" <> (toLogStr $ show t) <> " | error=" <> (toLogStr $ show e)
LogJobSuccess j t ->
"Success | " <> (jobToLogStr j) <> " | runtime=" <> (toLogStr $ show t)
LogJobTimeout j@Job{jobLockedAt, jobLockedBy} ->
"Timeout | " <> jobToLogStr j <> " | lockedBy=" <> (toLogStr $ maybe "unknown" unJobRunnerName jobLockedBy) <>
" lockedAt=" <> (toLogStr $ maybe "unknown" show jobLockedAt)
LogPoll ->
"Polling jobs table"
LogWebUIRequest ->
"WebUIRequest (TODO: Log the actual request)"
LogText t ->
toLogStr t
defaultJobToHtml :: (Job -> Text)
-> [Job]
-> IO [Html ()]
defaultJobToHtml jobType js =
pure $ DL.map jobToHtml js
where
jobToHtml :: Job -> Html ()
jobToHtml j = do
div_ [ class_ "job" ] $ do
div_ [ class_ "job-type" ] $ do
toHtml $ jobType j
div_ [ class_ "job-payload" ] $ do
defaultPayloadToHtml $ defaultJobContent $ jobPayload j
case jobLastError j of
Nothing -> mempty
Just e -> do
div_ [ class_ "job-error collapsed" ] $ do
a_ [ href_ "javascript: void(0);", onclick_ "toggleError(this)" ] $ do
span_ [ class_ "badge badge-secondary error-expand" ] "+ Last error"
span_ [ class_ "badge badge-secondary error-collapse d-none" ] "- Last error"
" "
defaultErrorToHtml e
defaultErrorToHtml :: Value -> Html ()
defaultErrorToHtml e =
case e of
Aeson.String s -> handleLineBreaks s
Aeson.Bool b -> toHtml $ show b
Aeson.Number n -> toHtml $ show n
Aeson.Null -> toHtml ("(null)" :: Text)
Aeson.Object o -> toHtml $ show o
Aeson.Array a -> toHtml $ show a
where
handleLineBreaks s = do
forM_ (T.splitOn "\n" s) $ \x -> do
toHtml x
br_ []
defaultJobContent :: Value -> Value
defaultJobContent v = case v of
Aeson.Object o -> case HM.lookup "contents" o of
Nothing -> v
Just c -> c
_ -> v
defaultPayloadToHtml :: Value -> Html ()
defaultPayloadToHtml v = case v of
Aeson.Object o -> do
toHtml ("{ " :: Text)
forM_ (HM.toList o) $ \(k, v2) -> do
span_ [ class_ " key-value-pair " ] $ do
span_ [ class_ "key" ] $ toHtml $ k <> ":"
span_ [ class_ "value" ] $ defaultPayloadToHtml v2
toHtml (" }" :: Text)
Aeson.Array a -> do
toHtml ("[" :: Text)
forM_ (toList a) $ \x -> do
defaultPayloadToHtml x
toHtml (", " :: Text)
toHtml ("]" :: Text)
Aeson.String t -> toHtml t
Aeson.Number n -> toHtml $ show n
Aeson.Bool b -> toHtml $ show b
Aeson.Null -> toHtml ("null" :: Text)
defaultJobTypeSql :: PGS.Query
defaultJobTypeSql = "payload->>'tag'"
defaultConstantJobTypes :: forall a . (Generic a, ConNames (Rep a))
=> Proxy a
-> AllJobTypes
defaultConstantJobTypes _ =
AJTFixed $ DL.map toS $ conNames (undefined :: a)
defaultDynamicJobTypes :: TableName
-> PGS.Query
-> AllJobTypes
defaultDynamicJobTypes tname jobTypeSql = AJTSql $ \conn -> do
fmap (DL.map ((fromMaybe "(unknown)") . fromOnly)) $ PGS.query_ conn $ "select distinct(" <> jobTypeSql <> ") from " <> tname <> " order by 1 nulls last"
defaultJobType :: Job -> Text
defaultJobType Job{jobPayload} =
case jobPayload of
Aeson.Object hm -> case HM.lookup "tag" hm of
Just (Aeson.String t) -> t
_ -> "unknown"
_ -> "unknown"
defaultPollingInterval :: Seconds
defaultPollingInterval = Seconds 5
withConnectionPool :: (MonadUnliftIO m)
=> Either BS.ByteString PGS.ConnectInfo
-> (Pool PGS.Connection -> m a)
-> m a
withConnectionPool connConfig action = withRunInIO $ \runInIO -> do
bracket poolCreator destroyAllResources (runInIO . action)
where
poolCreator = liftIO $
case connConfig of
Left connString ->
createPool (PGS.connectPostgreSQL connString) PGS.close 1 (fromIntegral $ 2 * (unSeconds defaultPollingInterval)) 8
Right connInfo ->
createPool (PGS.connect connInfo) PGS.close 1 (fromIntegral $ 2 * (unSeconds defaultPollingInterval)) 8
defaultTimedLogger :: FLogger.TimedFastLogger
-> (LogLevel -> LogEvent -> LogStr)
-> LogLevel
-> LogEvent
-> IO ()
defaultTimedLogger logger logStrFn logLevel logEvent =
if logLevel == LevelDebug
then pure ()
else logger $ \t -> (toLogStr t) <> " | " <>
(logStrFn logLevel logEvent) <>
"\n"
defaultJsonLogEvent :: LogEvent -> Aeson.Value
defaultJsonLogEvent logEvent =
case logEvent of
LogJobStart job ->
Aeson.object [ "tag" Aeson..= ("LogJobStart" :: Text)
, "contents" Aeson..= (defaultJsonJob job) ]
LogJobSuccess job runTime ->
Aeson.object [ "tag" Aeson..= ("LogJobSuccess" :: Text)
, "contents" Aeson..= (defaultJsonJob job, runTime) ]
LogJobFailed job e fm runTime ->
Aeson.object [ "tag" Aeson..= ("LogJobFailed" :: Text)
, "contents" Aeson..= (defaultJsonJob job, show e, defaultJsonFailureMode fm, runTime) ]
LogJobTimeout job ->
Aeson.object [ "tag" Aeson..= ("LogJobTimeout" :: Text)
, "contents" Aeson..= (defaultJsonJob job) ]
LogPoll ->
Aeson.object [ "tag" Aeson..= ("LogJobPoll" :: Text)]
LogWebUIRequest ->
Aeson.object [ "tag" Aeson..= ("LogWebUIRequest" :: Text)]
LogText t ->
Aeson.object [ "tag" Aeson..= ("LogText" :: Text)
, "contents" Aeson..= t ]
defaultJsonJob :: Job -> Aeson.Value
defaultJsonJob job = genericToJSON Aeson.defaultOptions job
defaultJsonFailureMode :: FailureMode -> Aeson.Value
defaultJsonFailureMode fm = genericToJSON Aeson.defaultOptions fm