packages feed

pgqueuer-hs-0.0.1.0: src/PGQueuer/Schema.hs

{-# LANGUAGE OverloadedStrings #-}

module PGQueuer.Schema (
    install,
    uninstall,
    verifyStructure_,
) where

import Data.String (fromString)
import qualified Data.Text as T
import Database.PostgreSQL.Simple (Connection, execute_)
import qualified Database.PostgreSQL.Simple as PG
import Database.PostgreSQL.Simple.Types (Only (..))
import PGQueuer.Settings

-- | Install the schema in the database
install :: Connection -> DBSettings -> IO ()
install conn _settings = do
    _ <- execute_ conn "CREATE TYPE pgqueuer_status AS ENUM ('queued', 'picked', 'successful', 'exception', 'canceled', 'deleted', 'failed');"
    _ <- execute_ conn $ textToQuery queueTableSQL
    _ <- execute_ conn $ textToQuery queueLogTableSQL
    _ <- execute_ conn $ textToQuery statisticsTableSQL
    _ <- execute_ conn $ textToQuery schedulesTableSQL
    _ <- execute_ conn $ textToQuery triggerFunctionSQL
    _ <- execute_ conn $ textToQuery triggerSQL
    return ()

-- | Uninstall the schema from the database
uninstall :: Connection -> DBSettings -> IO ()
uninstall conn _settings = do
    _ <- execute_ conn "DROP TRIGGER IF EXISTS tg_pgqueuer_changed ON pgqueuer;"
    _ <- execute_ conn "DROP TABLE IF EXISTS pgqueuer;"
    _ <- execute_ conn "DROP TABLE IF EXISTS pgqueuer_log;"
    _ <- execute_ conn "DROP TABLE IF EXISTS pgqueuer_statistics;"
    _ <- execute_ conn "DROP TABLE IF EXISTS pgqueuer_schedules;"
    _ <- execute_ conn "DROP TYPE IF EXISTS pgqueuer_status;"
    _ <- execute_ conn "DROP FUNCTION IF EXISTS fn_pgqueuer_changed();"
    return ()

-- | Verify the schema is properly installed
verifyStructure_ :: Connection -> DBSettings -> IO (Either String ())
verifyStructure_ conn _settings = do
    result <- PG.query_ conn "SELECT EXISTS (SELECT FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'pgqueuer');" :: IO [Only Bool]
    case result of
        [Only True] -> return $ Right ()
        _ -> return $ Left "Queue table is missing. Please run 'pgqueuer install' to set up the schema."

textToQuery :: T.Text -> PG.Query
textToQuery = fromString . T.unpack

queueTableSQL :: T.Text
queueTableSQL =
    T.unlines
        [ "CREATE TABLE IF NOT EXISTS pgqueuer ("
        , "    id SERIAL PRIMARY KEY,"
        , "    priority INT NOT NULL,"
        , "    queue_manager_id UUID,"
        , "    created TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,"
        , "    updated TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,"
        , "    heartbeat TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,"
        , "    execute_after TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,"
        , "    status pgqueuer_status NOT NULL,"
        , "    entrypoint TEXT NOT NULL,"
        , "    dedupe_key TEXT,"
        , "    payload BYTEA,"
        , "    headers JSONB,"
        , "    attempts INT NOT NULL DEFAULT 0"
        , ");"
        , "CREATE INDEX IF NOT EXISTS pgqueuer_priority_id_idx ON pgqueuer (priority ASC, id DESC)"
        , "    INCLUDE (id) WHERE status = 'queued';"
        , "CREATE INDEX IF NOT EXISTS pgqueuer_updated_id_idx ON pgqueuer (updated ASC, id DESC)"
        , "    INCLUDE (id) WHERE status = 'picked';"
        , "CREATE INDEX IF NOT EXISTS pgqueuer_manager_id_idx ON pgqueuer (queue_manager_id)"
        , "    WHERE queue_manager_id IS NOT NULL;"
        , "CREATE INDEX IF NOT EXISTS pgqueuer_ep_prio_id_idx ON pgqueuer (entrypoint, priority DESC, id ASC)"
        , "    WHERE status = 'queued';"
        , "CREATE INDEX IF NOT EXISTS pgqueuer_ep_ea_idx ON pgqueuer (entrypoint, execute_after)"
        , "    WHERE status = 'queued';"
        , "CREATE UNIQUE INDEX IF NOT EXISTS pgqueuer_unique_dedupe_key ON pgqueuer (dedupe_key)"
        , "    WHERE ((status IN ('queued', 'picked') AND dedupe_key IS NOT NULL));"
        ]

queueLogTableSQL :: T.Text
queueLogTableSQL =
    T.unlines
        [ "CREATE UNLOGGED TABLE IF NOT EXISTS pgqueuer_log ("
        , "    id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,"
        , "    created TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,"
        , "    job_id BIGINT NOT NULL,"
        , "    status pgqueuer_status NOT NULL,"
        , "    priority INT NOT NULL,"
        , "    entrypoint TEXT NOT NULL,"
        , "    traceback JSONB DEFAULT NULL,"
        , "    aggregated BOOLEAN DEFAULT FALSE"
        , ");"
        , "CREATE INDEX IF NOT EXISTS pgqueuer_log_not_aggregated ON pgqueuer_log (entrypoint, priority, status, created) WHERE not aggregated;"
        , "CREATE INDEX IF NOT EXISTS pgqueuer_log_created ON pgqueuer_log (created);"
        , "CREATE INDEX IF NOT EXISTS pgqueuer_log_status ON pgqueuer_log (status);"
        , "CREATE INDEX IF NOT EXISTS pgqueuer_log_job_id_status ON pgqueuer_log (job_id, created DESC);"
        ]

statisticsTableSQL :: T.Text
statisticsTableSQL =
    T.unlines
        [ "CREATE TABLE IF NOT EXISTS pgqueuer_statistics ("
        , "    id SERIAL PRIMARY KEY,"
        , "    created TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT DATE_TRUNC('sec', NOW() at time zone 'UTC'),"
        , "    count BIGINT NOT NULL,"
        , "    priority INT NOT NULL,"
        , "    status pgqueuer_status NOT NULL,"
        , "    entrypoint TEXT NOT NULL"
        , ");"
        , "CREATE UNIQUE INDEX IF NOT EXISTS pgqueuer_statistics_unique_count ON pgqueuer_statistics ("
        , "    priority,"
        , "    DATE_TRUNC('sec', created at time zone 'UTC'),"
        , "    status,"
        , "    entrypoint"
        , ");"
        ]

schedulesTableSQL :: T.Text
schedulesTableSQL =
    T.unlines
        [ "CREATE TABLE IF NOT EXISTS pgqueuer_schedules ("
        , "    id SERIAL PRIMARY KEY,"
        , "    expression TEXT NOT NULL,"
        , "    entrypoint TEXT NOT NULL,"
        , "    heartbeat TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,"
        , "    created TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,"
        , "    updated TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,"
        , "    next_run TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,"
        , "    last_run TIMESTAMP WITH TIME ZONE,"
        , "    status pgqueuer_status DEFAULT 'queued',"
        , "    UNIQUE (expression, entrypoint)"
        , ");"
        ]

triggerFunctionSQL :: T.Text
triggerFunctionSQL =
    T.unlines
        [ "CREATE OR REPLACE FUNCTION fn_pgqueuer_changed() RETURNS TRIGGER AS $$"
        , "DECLARE"
        , "    to_emit BOOLEAN := false;"
        , "BEGIN"
        , "    IF TG_OP = 'UPDATE' AND OLD IS DISTINCT FROM NEW THEN"
        , "        to_emit := true;"
        , "    ELSIF TG_OP = 'DELETE' THEN"
        , "        to_emit := true;"
        , "    ELSIF TG_OP = 'INSERT' THEN"
        , "        to_emit := true;"
        , "    ELSIF TG_OP = 'TRUNCATE' THEN"
        , "        to_emit := true;"
        , "    END IF;"
        , ""
        , "    IF to_emit THEN"
        , "        PERFORM pg_notify("
        , "            'pgqueuer',"
        , "            json_build_object("
        , "                'channel', 'pgqueuer',"
        , "                'operation', lower(TG_OP),"
        , "                'sent_at', NOW(),"
        , "                'table', TG_TABLE_NAME,"
        , "                'type', 'table_changed_event'"
        , "            )::text"
        , "        );"
        , "    END IF;"
        , ""
        , "    IF TG_OP IN ('INSERT', 'UPDATE') THEN"
        , "        RETURN NEW;"
        , "    ELSIF TG_OP = 'DELETE' THEN"
        , "        RETURN OLD;"
        , "    ELSE"
        , "        RETURN NULL;"
        , "    END IF;"
        , "END;"
        , "$$ LANGUAGE plpgsql;"
        ]

triggerSQL :: T.Text
triggerSQL =
    T.unlines
        [ "CREATE TRIGGER tg_pgqueuer_changed"
        , "AFTER INSERT OR UPDATE OR DELETE ON pgqueuer"
        , "FOR EACH ROW EXECUTE FUNCTION fn_pgqueuer_changed();"
        , "CREATE TRIGGER tg_pgqueuer_changed_truncate"
        , "AFTER TRUNCATE ON pgqueuer"
        , "FOR EACH STATEMENT EXECUTE FUNCTION fn_pgqueuer_changed();"
        ]