packages feed

pgmq-config-0.5.0.0: src/Pgmq/Config.hs

-- | Declare a pgmq queue topology as Haskell values and create whatever is
-- missing, in one call at application startup.
--
-- Build configs with 'standardQueue', 'unloggedQueue', or 'partitionedQueue'
-- and refine them with 'withNotifyInsert', 'withFifoIndex', and
-- 'withTopicBinding'; then hand the list to 'ensureQueues' (or
-- 'ensureQueuesWithPool'), or to 'ensureQueuesReport' when you want to see what
-- was done. 'ensureQueues' documents the reconciliation contract in full —
-- what is created, the one case in which existing state is mutated, what is
-- reported as drift instead of repaired, and the concurrent-startup caveat.
--
-- The same reconciler is available over the @Pgmq@ effect in
-- "Pgmq.Config.Effectful".
module Pgmq.Config
  ( -- * Queue Configuration Types
    QueueConfig (..),
    QueueType (..),
    PartitionConfig (..),
    NotifyConfig (..),

    -- * Smart Constructors
    standardQueue,
    unloggedQueue,
    partitionedQueue,

    -- * Modifiers
    withNotifyInsert,
    withFifoIndex,
    withTopicBinding,

    -- * Reconciliation
    ensureQueues,
    ensureQueuesWithPool,

    -- * Reconciliation with Report
    ReconcileAction (..),
    ObservedQueueType (..),
    ensureQueuesReport,

    -- * Defaults
    defaultThrottleMs,
  )
where

import Hasql.Pool qualified as Pool
import Hasql.Session (Session)
import Pgmq.Config.Reconcile (ReconcileOps (..), ensureQueuesReportWith)
import Pgmq.Config.Types
import Pgmq.Hasql.Sessions qualified as Sessions

-- | The 'Session'-backed wiring of the reconciliation operations.
sessionOps :: ReconcileOps Session
sessionOps =
  ReconcileOps
    { listQueuesUnvalidated = Sessions.listQueuesUnvalidated,
      listTopicBindings = Sessions.listTopicBindings,
      listNotifyInsertThrottles = Sessions.listNotifyInsertThrottles,
      createQueue = Sessions.createQueue,
      createUnloggedQueue = Sessions.createUnloggedQueue,
      createPartitionedQueue = Sessions.createPartitionedQueue,
      enableNotifyInsert = Sessions.enableNotifyInsert,
      createFifoIndex = Sessions.createFifoIndex,
      bindTopic = Sessions.bindTopic,
      listFifoIndexQueueNames = Sessions.listFifoIndexQueueNames,
      updateNotifyInsert = Sessions.updateNotifyInsert
    }

-- | Create whatever the declared configs call for that does not exist yet.
--
-- Reconciliation is /additive/. It snapshots existing queues, topic bindings,
-- notification throttles, and FIFO indexes with four read-only queries, then
-- issues mutating calls only for the pieces that are missing: it creates
-- queues, enables insert notifications, creates FIFO indexes, and binds topic
-- patterns. It never drops, converts, or disables anything, and a queue that
-- exists in the database but not in the config is left completely alone.
--
-- There is exactly one exception, and it is deliberate: if a config declares a
-- notification throttle interval that differs from the one stored in the
-- database, the stored value is updated in place via
-- @pgmq.update_notify_insert@ and reported as
-- 'Pgmq.Config.Types.UpdatedNotifyThrottle'. That update also resets the
-- throttle's @last_notified_at@ to the epoch, so the next insert on that queue
-- raises a notification immediately — once, after a genuine configuration
-- change. A throttle whose interval already matches is not touched at all; in
-- particular it is not re-enabled, because re-enabling would reset the same
-- timestamp on every startup. @throttleMs = Nothing@ compares equal to
-- 'defaultThrottleMs', so a defaulted config does not flap.
--
-- What is /not/ reconciled:
--
-- * A queue whose observed shape (standard, unlogged, or partitioned)
--   contradicts the declared one is reported as
--   'Pgmq.Config.Types.DetectedQueueTypeDrift' and nothing is mutated.
--   Changing a queue's type means dropping and recreating it, destroying every
--   message it holds; that is an operator's decision, not a startup task.
--
-- * A partitioned queue's partition interval and retention interval are never
--   compared, because @pgmq.list_queues()@ does not report them. Only the
--   three-way shape is checked.
--
-- Calling this on every application startup is the intended usage, and a second
-- run against an unchanged config issues no mutations at all. One caveat about
-- /concurrent/ startups: queue creation and topic binding are serialized
-- server-side and converge, but enabling insert notifications on a
-- brand-new queue from two replicas at once can fail one of them with SQLSTATE
-- 42710 (@duplicate_object@) when pgmq was installed as the stock upstream
-- 1.11.0 extension. Databases installed through this repository's
-- @pgmq-migration@ package are free of that race — its migration
-- @0003-notify-crash-safety-and-locking.sql@ takes a per-queue advisory lock
-- inside @enable_notify_insert@. On an extension install, either retry the
-- reconcile (every operation is convergent, so a retry succeeds) or serialize
-- startup reconciliation across replicas. FIFO index creation has the same
-- narrow shape of race and is likewise harmless on retry, since the underlying
-- statement is @CREATE INDEX IF NOT EXISTS@.
--
-- Reconciliation is not wrapped in a transaction: each call autocommits, so a
-- failure part-way leaves the work done so far in place and the next run
-- continues from there.
ensureQueues :: [QueueConfig] -> Session ()
ensureQueues configs = () <$ ensureQueuesReport configs

-- | Convenience wrapper that runs 'ensureQueues' against a connection pool.
ensureQueuesWithPool :: Pool.Pool -> [QueueConfig] -> IO (Either Pool.UsageError ())
ensureQueuesWithPool pool configs =
  Pool.use pool (ensureQueues configs)

-- | Like 'ensureQueues', but returns a report of what was done.
--
-- The contract is identical — see 'ensureQueues' for the full description of
-- what is and is not reconciled, and for the concurrent-startup caveat. The
-- report contains one action per decision the reconciler made, in declaration
-- order: exactly one queue-existence action per config
-- ('Pgmq.Config.Types.CreatedQueue', 'Pgmq.Config.Types.SkippedQueue', or
-- 'Pgmq.Config.Types.DetectedQueueTypeDrift'), then the notify, FIFO, and
-- topic-binding actions for that config. Every @Skipped@ action means no
-- statement was issued.
ensureQueuesReport :: [QueueConfig] -> Session [ReconcileAction]
ensureQueuesReport = ensureQueuesReportWith sessionOps