packages feed

consumers-2.3.2.0: example/Example.hs

{-# LANGUAGE ConstraintKinds #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE ImportQualifiedPost #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}

module Main where

import Control.Concurrent
import Control.Exception
import Control.Monad
import Control.Monad.IO.Class
import Data.Int
import Data.Text qualified as T
import Database.PostgreSQL.Consumers
import Database.PostgreSQL.PQTypes
import Database.PostgreSQL.PQTypes.Checks
import Database.PostgreSQL.PQTypes.Model
import Log
import Log.Backend.StandardOutput
import System.Environment
import System.Exit
import TextShow

-- | Main application monad. See the 'log-base' and the 'hpqtypes'
-- packages for documentation on 'DBT' and 'LogT'.
type AppM a = DBT (LogT IO) a

main :: IO ()
main = do
  connString <-
    getArgs >>= \case
      connString : _args -> pure $ T.pack connString
      [] ->
        lookupEnv "GITHUB_ACTIONS" >>= \case
          Just "true" -> pure "host=postgres user=postgres password=postgres"
          _ -> printUsage >> exitFailure

  let connSettings =
        defaultConnectionSettings
          { csConnInfo = connString
          }
      ConnectionSource connSource = simpleSource connSettings

  -- Monad stack initialisation.
  withStdOutLogger $ \logger ->
    runLogT "consumers-example" logger defaultLogLevel
      . runDBT connSource defaultTransactionSettings
      $ do
        -- Initialise.
        createTables

        -- Create a consumer, put ten jobs into its queue, and wait
        -- for it to finish. 'runConsumer' returns a finaliser that is
        -- invoked by 'finalize' after the 'putJob' loop.
        finalize
          ( localDomain "process" $
              runConsumer consumerConfig connSource
          )
          . forM_ [(0 :: Int) .. 10]
          $ \_ -> do
            putJob
            liftIO $ threadDelay (1 * 1000000) -- 1 sec

        -- Clean up.
        dropTables
  where
    printUsage = do
      prog <- getProgName
      putStrLn $ "Usage: " <> prog <> " <connection info string>"

    tables = [consumersTable, jobsTable]
    -- NB: order of migrations is important.
    migrations =
      [ createTableMigration consumersTable
      , createTableMigration jobsTable
      ]

    createTables :: AppM ()
    createTables = do
      migrateDatabase
        defaultExtrasOptions
        [] -- extensions
        [] -- composites
        [] -- domains
        tables
        migrations
      checkDatabase
        defaultExtrasOptions
        [] -- composites
        [] -- domains
        tables

    dropTables :: AppM ()
    dropTables = do
      migrateDatabase
        defaultExtrasOptions
        [] -- extensions
        [] -- composites
        [] -- domains
        [] -- tables
        [ dropTableMigration jobsTable
        , dropTableMigration consumersTable
        ]

    -- Configuration of a consumer. See
    -- 'Database.PostgreSQL.Consumers.Config.ConsumerConfig'.
    consumerConfig =
      ConsumerConfig
        { ccJobsTable = "consumers_example_jobs"
        , ccConsumersTable = "consumers_example_consumers"
        , ccJobSelectors = ["id", "message"]
        , ccJobFetcher = id
        , ccJobIndex = \(i :: Int64, _msg :: T.Text) -> i
        , ccNotificationChannel = Just "consumers_example_chan"
        , ccNotificationTimeout = 10 * 1000000 -- 10 sec
        , ccMaxRunningJobs = 1
        , ccProcessJob = processJob
        , ccOnException = handleException
        }

    -- Add a job to the consumer's queue.
    putJob :: AppM ()
    putJob = localDomain "put" $ do
      logInfo_ "putJob"
      runSQL_ $
        "INSERT INTO consumers_example_jobs "
          <> "(run_at, finished_at, reserved_by, attempts, message) "
          <> "VALUES (NOW(), NULL, NULL, 0, 'hello')"
      notify "consumers_example_chan" ""
      commit

    -- Invoked when a job is ready to be processed.
    processJob :: (Int64, T.Text) -> AppM Result
    processJob (_idx, msg) = do
      logInfo_ msg
      pure (Ok Remove)

    -- Invoked when 'processJob' throws an exception. Can handle
    -- failure in different ways, such as: remove the job from the
    -- queue, mark it as processed, or schedule it for rerun.
    handleException :: SomeException -> (Int64, T.Text) -> AppM Action
    handleException exc (idx, _msg) = do
      logAttention "Job failed" $
        object
          [ "job_id" .= showt idx
          , "exception" .= showt exc
          ]
      pure . RerunAfter $ imicroseconds 500000

-- | Table where jobs are stored. See
-- 'Database.PostgreSQL.Consumers.Config.ConsumerConfig'.
jobsTable :: Table
jobsTable =
  tblTable
    { tblName = "consumers_example_jobs"
    , tblVersion = 1
    , tblColumns =
        [ tblColumn
            { colName = "id"
            , colType = BigSerialT
            , colNullable = False
            }
        , tblColumn
            { colName = "run_at"
            , colType = TimestampWithZoneT
            , colNullable = True
            }
        , tblColumn
            { colName = "finished_at"
            , colType = TimestampWithZoneT
            , colNullable = True
            }
        , tblColumn
            { colName = "reserved_by"
            , colType = BigIntT
            , colNullable = True
            }
        , tblColumn
            { colName = "attempts"
            , colType = IntegerT
            , colNullable = False
            }
        , -- The only non-obligatory field:
          tblColumn
            { colName = "message"
            , colType = TextT
            , colNullable = False
            }
        ]
    , tblPrimaryKey = pkOnColumn "id"
    , tblForeignKeys =
        [ (fkOnColumn "reserved_by" "consumers_example_consumers" "id")
            { fkOnDelete = ForeignKeySetNull
            }
        ]
    }

-- | Table where registered consumers are stored. See
-- 'Database.PostgreSQL.Consumers.Config.ConsumerConfig'.
consumersTable :: Table
consumersTable =
  tblTable
    { tblName = "consumers_example_consumers"
    , tblVersion = 1
    , tblColumns =
        [ tblColumn
            { colName = "id"
            , colType = BigSerialT
            , colNullable = False
            }
        , tblColumn
            { colName = "name"
            , colType = TextT
            , colNullable = False
            }
        , tblColumn
            { colName = "last_activity"
            , colType = TimestampWithZoneT
            , colNullable = False
            }
        ]
    , tblPrimaryKey = pkOnColumn "id"
    }

createTableMigration :: MonadDB m => Table -> Migration m
createTableMigration tbl =
  Migration
    { mgrTableName = tblName tbl
    , mgrFrom = 0
    , mgrAction = StandardMigration $ do
        createTable True tbl
    }

dropTableMigration :: Table -> Migration m
dropTableMigration tbl =
  Migration
    { mgrTableName = tblName tbl
    , mgrFrom = 1
    , mgrAction = DropTableMigration DropTableRestrict
    }