packages feed

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

-- | The backend-agnostic reconciliation core shared by "Pgmq.Config" (which
-- runs it in 'Hasql.Session.Session') and "Pgmq.Config.Effectful" (which runs
-- it in the @Pgmq@ effect). The logic lives here exactly once; the two public
-- modules only supply a t'ReconcileOps' record wiring the database calls.
--
-- This module is internal: it is listed under @other-modules@ in
-- @pgmq-config.cabal@ and is not part of the package's public API.
module Pgmq.Config.Reconcile
  ( ReconcileOps (..),
    ensureQueuesReportWith,
  )
where

import Control.Lens ((^.))
import Data.Generics.Labels ()
import Data.Int (Int32)
import Data.Map.Strict qualified as Map
import Data.Maybe (fromMaybe)
import Data.Set qualified as Set
import Data.Text qualified as T
import GHC.Generics (Generic)
import Pgmq.Config.Types
import Pgmq.Hasql.Statements.Types qualified as StmtTypes
import Pgmq.Types
  ( NotifyInsertThrottle,
    QueueName,
    TopicBinding,
    TopicPattern,
    UnvalidatedQueue,
    queueNameToText,
    topicPatternToText,
  )

-- | The database operations the reconciler needs, abstracted over the carrier
-- monad so one implementation serves both the 'Hasql.Session.Session' and
-- @Pgmq@-effect entry points.
data ReconcileOps m = ReconcileOps
  { -- | Deliberately the /unvalidated/ listing: the reconciler only compares
    -- observed names against declared ones, and re-validating them would make
    -- one foreign queue whose name 'Pgmq.Types.parseQueueName' rejects fail the
    -- whole reconcile at application startup.
    listQueuesUnvalidated :: m [UnvalidatedQueue],
    listTopicBindings :: m [TopicBinding],
    listNotifyInsertThrottles :: m [NotifyInsertThrottle],
    createQueue :: QueueName -> m (),
    createUnloggedQueue :: QueueName -> m (),
    createPartitionedQueue :: StmtTypes.CreatePartitionedQueue -> m (),
    enableNotifyInsert :: StmtTypes.EnableNotifyInsert -> m (),
    createFifoIndex :: QueueName -> m (),
    bindTopic :: StmtTypes.BindTopic -> m (),
    -- | Queues that already carry their FIFO headers index, read from the
    -- @pg_indexes@ catalog. pgmq has no index-existence function, so without
    -- this the reconciler cannot say truthfully whether it created one.
    listFifoIndexQueueNames :: m [T.Text],
    -- | The reconciler's only mutation of already-existing state: bring a
    -- throttle row's interval in line with the declared one.
    updateNotifyInsert :: StmtTypes.UpdateNotifyInsert -> m ()
  }
  deriving stock (Generic)

-- | Reconcile the declared configs against existing state, returning a report
-- of actions taken. Queries existing state first and skips operations that are
-- already satisfied.
ensureQueuesReportWith ::
  (Monad m) =>
  ReconcileOps m ->
  [QueueConfig] ->
  m [ReconcileAction]
ensureQueuesReportWith ops configs = do
  existingQueues <- ops ^. #listQueuesUnvalidated
  existingBindings <- ops ^. #listTopicBindings
  existingThrottles <- ops ^. #listNotifyInsertThrottles
  existingFifoIndexes <- ops ^. #listFifoIndexQueueNames

  let existingQueuesByName =
        Map.fromList [(q ^. #unvalidatedName, q) | q <- existingQueues]
      existingBindingSet =
        Set.fromList
          [ (b ^. #bindingQueueName, topicPatternToText (b ^. #bindingPattern))
          | b <- existingBindings
          ]
      -- The interval is kept, not just the name: dropping it is what made
      -- declared-versus-stored throttle drift invisible.
      existingNotifyByName =
        Map.fromList
          [(t ^. #throttleQueueName, t ^. #throttleIntervalMs) | t <- existingThrottles]
      -- The catalog reports the lowercased physical form pgmq derives table
      -- names from. Declared names are lowercase-only (parseQueueName rejects
      -- anything else), so matching them textually is exact.
      existingFifoSet = Set.fromList existingFifoIndexes

  concat
    <$> traverse
      (reconcileQueue ops existingQueuesByName existingBindingSet existingNotifyByName existingFifoSet)
      configs

-- | Reconcile a single queue config against existing state, returning actions taken.
reconcileQueue ::
  (Monad m) =>
  ReconcileOps m ->
  Map.Map T.Text UnvalidatedQueue ->
  Set.Set (T.Text, T.Text) ->
  Map.Map T.Text Int32 ->
  Set.Set T.Text ->
  QueueConfig ->
  m [ReconcileAction]
reconcileQueue ops existingQueues existingBindings existingNotify existingFifo cfg = do
  let qn = cfg ^. #queueName
      qnText = queueNameToText qn
      declaredType = cfg ^. #queueType

  -- Queue creation, or — when the queue is already there — a shape comparison.
  -- Drift is reported and never repaired: the only way to change a queue's type
  -- is to drop and recreate it, which would destroy its messages.
  queueAction <-
    case Map.lookup qnText existingQueues of
      Just observed ->
        let observedType = observedQueueType observed
         in pure
              [ if declaredShape declaredType == observedType
                  then SkippedQueue qn
                  else DetectedQueueTypeDrift qn declaredType observedType
              ]
      Nothing -> do
        case declaredType of
          StandardQueue ->
            (ops ^. #createQueue) qn
          UnloggedQueue ->
            (ops ^. #createUnloggedQueue) qn
          PartitionedQueue pc ->
            (ops ^. #createPartitionedQueue)
              StmtTypes.CreatePartitionedQueue
                { queueName = qn,
                  partitionInterval = pc ^. #partitionInterval,
                  retentionInterval = pc ^. #retentionInterval
                }
        pure [CreatedQueue qn declaredType]

  -- Notification. A missing row is enabled; a row whose interval already
  -- matches is left strictly alone (re-enabling would reset last_notified_at);
  -- a row whose interval differs is updated in place.
  notifyAction <- case cfg ^. #notifyInsert of
    Nothing -> pure []
    Just nc ->
      let declaredMs = fromMaybe defaultThrottleMs (nc ^. #throttleMs)
       in case Map.lookup qnText existingNotify of
            Nothing -> do
              (ops ^. #enableNotifyInsert)
                StmtTypes.EnableNotifyInsert
                  { queueName = qn,
                    throttleIntervalMs = nc ^. #throttleMs
                  }
              pure [EnabledNotify qn (nc ^. #throttleMs)]
            Just observedMs
              | observedMs == declaredMs -> pure [SkippedNotify qn]
              | otherwise -> do
                  (ops ^. #updateNotifyInsert)
                    StmtTypes.UpdateNotifyInsert
                      { queueName = qn,
                        throttleIntervalMs = declaredMs
                      }
                  pure [UpdatedNotifyThrottle qn observedMs declaredMs]

  -- FIFO index. The catalog snapshot makes the report truthful; the underlying
  -- pgmq.create_fifo_index is CREATE INDEX IF NOT EXISTS, so losing a race with
  -- a concurrent replica degrades to a no-op rather than an error.
  fifoAction <-
    if cfg ^. #fifoIndex
      then
        if Set.member qnText existingFifo
          then pure [SkippedFifoIndex qn]
          else do
            (ops ^. #createFifoIndex) qn
            pure [CreatedFifoIndex qn]
      else pure []

  -- Topic bindings
  bindingActions <- concat <$> traverse (reconcileBinding ops qn qnText existingBindings) (cfg ^. #topicBindings)

  pure (queueAction ++ notifyAction ++ fifoAction ++ bindingActions)

-- | The shape a declared config asks for, reduced to what @pgmq.list_queues()@
-- can actually report. Partition interval and retention are deliberately
-- dropped here: the listing does not expose them, so they are not drift-checked.
declaredShape :: QueueType -> ObservedQueueType
declaredShape StandardQueue = ObservedStandard
declaredShape UnloggedQueue = ObservedUnlogged
declaredShape (PartitionedQueue _) = ObservedPartitioned

-- | The shape an observed queue actually has, from the two booleans
-- @pgmq.list_queues()@ reports.
observedQueueType :: UnvalidatedQueue -> ObservedQueueType
observedQueueType q
  | q ^. #unvalidatedIsPartitioned = ObservedPartitioned
  | q ^. #unvalidatedIsUnlogged = ObservedUnlogged
  | otherwise = ObservedStandard

-- | Reconcile a single topic binding.
reconcileBinding ::
  (Monad m) =>
  ReconcileOps m ->
  QueueName ->
  T.Text ->
  Set.Set (T.Text, T.Text) ->
  TopicPattern ->
  m [ReconcileAction]
reconcileBinding ops qn qnText existingBindings pat =
  let patText = topicPatternToText pat
   in if Set.member (qnText, patText) existingBindings
        then pure [SkippedTopicBinding qn pat]
        else do
          (ops ^. #bindTopic)
            StmtTypes.BindTopic
              { topicPattern = pat,
                queueName = qn
              }
          pure [BoundTopic qn pat]