packages feed

pgmq-config-0.7.0.0: src/Pgmq/Config/Types.hs

-- | The declarative vocabulary: what a queue topology looks like as Haskell
-- values, and what a reconciliation run reports back.
--
-- t'QueueConfig' is the declaration; 'ReconcileAction' is the report. Everything
-- here is re-exported from "Pgmq.Config", which is the module to import.
module Pgmq.Config.Types
  ( -- * Queue Configuration
    QueueConfig (..),
    QueueType (..),
    PartitionConfig (..),
    NotifyConfig (..),

    -- * Smart Constructors
    standardQueue,
    unloggedQueue,
    partitionedQueue,

    -- * Modifiers
    withNotifyInsert,
    withFifoIndex,
    withTopicBinding,

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

    -- * Defaults
    defaultThrottleMs,
  )
where

import Control.Lens ((%~), (&), (^.))
import Data.Aeson (Encoding, KeyValue, Series, ToJSON (..), object, pairs, (.=))
import Data.Generics.Labels ()
import Data.Int (Int32)
import Data.List.NonEmpty (NonEmpty)
import Data.Text (Text)
import GHC.Generics (Generic)
import Pgmq.Types (QueueName, TopicPattern)

-- | Describes the desired state of a single pgmq queue.
data QueueConfig = QueueConfig
  { queueName :: !QueueName,
    queueType :: !QueueType,
    notifyInsert :: !(Maybe NotifyConfig),
    fifoIndex :: !Bool,
    topicBindings :: ![TopicPattern]
  }
  deriving stock (Generic, Show)

-- | The type of queue to create.
data QueueType
  = -- | A standard queue with write-ahead logging.
    StandardQueue
  | -- | An unlogged queue — faster writes, but data is lost on crash.
    UnloggedQueue
  | -- | A partitioned queue for high-throughput scenarios.
    PartitionedQueue !PartitionConfig
  deriving stock (Show)

-- | Configuration for a partitioned queue.
data PartitionConfig = PartitionConfig
  { partitionInterval :: !Text,
    retentionInterval :: !Text,
    -- | Creation only: Nothing uses the server default via the legacy call;
    -- Just requires PGMQ 1.13+ and a count of at least one. Existing queues
    -- are neither checked nor reconfigured for this setting.
    premake :: !(Maybe Int32)
  }
  deriving stock (Generic, Show)

-- | Configuration for insert notifications (LISTEN/NOTIFY).
data NotifyConfig = NotifyConfig
  { -- | Minimum milliseconds between notifications. Nothing uses the documented
    -- pgmq default (250 ms), applied via COALESCE in the pgmq-hasql statement so
    -- SQL NULL never reaches the function.
    throttleMs :: !(Maybe Int32)
  }
  deriving stock (Generic, Show)

-- | The queue shape actually observed in the database.
--
-- @pgmq.list_queues()@ reports two booleans per queue, partitioned and
-- unlogged, which describe exactly these three states. It reports nothing about
-- a partitioned queue's interval or retention settings, so those are not
-- drift-checked — see 'DetectedQueueTypeDrift'.
data ObservedQueueType
  = ObservedStandard
  | ObservedUnlogged
  | ObservedPartitioned
  deriving stock (Eq, Show)

-- | An action taken (or skipped) during queue reconciliation.
data ReconcileAction
  = -- | The queue did not exist and was created with the declared type.
    CreatedQueue !QueueName !QueueType
  | -- | No throttle row existed, so insert notifications were enabled with the
    -- declared interval ('Nothing' meaning 'defaultThrottleMs').
    EnabledNotify !QueueName !(Maybe Int32)
  | -- | The FIFO headers index did not exist and was created.
    CreatedFifoIndex !QueueName
  | -- | The topic binding did not exist and was created.
    BoundTopic !QueueName !TopicPattern
  | -- | A queue with this name already existed and its observed type matches
    -- what was declared. Nothing was issued.
    SkippedQueue !QueueName
  | -- | A throttle row already existed with the declared interval. Nothing was
    -- issued — in particular the row was /not/ re-enabled, which would reset
    -- its @last_notified_at@.
    SkippedNotify !QueueName
  | -- | The FIFO headers index already existed; nothing was issued.
    SkippedFifoIndex !QueueName
  | -- | The topic binding already existed; nothing was issued.
    SkippedTopicBinding !QueueName !TopicPattern
  | -- | The declared throttle interval differed from the database row, so the
    -- row was updated in place via @pgmq.update_notify_insert@. Fields: queue,
    -- observed interval, declared interval (in milliseconds).
    --
    -- This is the reconciler's only mutation of already-existing state. The
    -- update also resets the throttle's @last_notified_at@ to the epoch, so the
    -- next insert on that queue notifies immediately; that is a property of
    -- @pgmq.update_notify_insert@ itself, and it happens at most once per real
    -- configuration change.
    UpdatedNotifyThrottle !QueueName !Int32 !Int32
  | -- | The queue exists but its observed shape contradicts the declared one.
    -- Fields: queue, declared type, observed type.
    --
    -- The queue itself is not mutated: conversion between standard, unlogged,
    -- and partitioned requires destroying its messages. Notification, FIFO-index
    -- and topic-binding declarations are still reconciled against the observed
    -- queue and each reports its own action. Resolving drift is an operator
    -- decision. This action replaces
    -- 'SkippedQueue' for the queue it concerns, so the report still carries
    -- exactly one queue-existence action per declared config.
    --
    -- Only the three-way shape is compared. A declared 'PartitionedQueue'
    -- against an observed partitioned queue matches regardless of its interval
    -- and retention settings, because @pgmq.list_queues()@ does not report
    -- them.
    DetectedQueueTypeDrift !QueueName !QueueType !ObservedQueueType
  | -- | Foreign registry names share the declared name's lowercased physical
    -- table. Fields: declared queue, every colliding registry name in ascending
    -- lexical order. Without an exact row this replaces 'CreatedQueue' and no
    -- notification, FIFO-index or binding declarations run. With an exact row,
    -- this follows the existence action and sub-resources proceed on that row.
    -- This action mutates nothing. Detection uses the initial snapshot; a foreign
    -- client can still create an alias afterward. Resolving aliases follows
    -- @docs/design/016-queue-name-validation.md@.
    DetectedQueueNameCollision !QueueName !(NonEmpty Text)
  | -- | Insert notifications were declared on an observed or newly created
    -- partitioned queue. Its cloned leaf triggers cannot deliver on
    -- 'Pgmq.Types.notifyChannelName'; see
    -- @docs/design/015-notification-delivery-contract.md@. Nothing was enabled,
    -- updated or re-enabled; an existing foreign throttle row is left unchanged.
    UnsupportedNotifyOnPartitionedQueue !QueueName
  deriving stock (Show)

-- | The throttle interval pgmq applies when none is given: 250 milliseconds.
--
-- A t'NotifyConfig' whose @throttleMs@ is 'Nothing' means \"use this value\".
-- The pgmq-hasql enable statement supplies it with a SQL @coalesce($2, 250)@,
-- and @pgmq.enable_notify_insert@ declares the same figure as its parameter
-- default, so a 'Nothing' config and a stored 250 agree and reconciliation does
-- not flap between them.
defaultThrottleMs :: Int32
defaultThrottleMs = 250

-- | Create a standard queue configuration with no extras.
standardQueue :: QueueName -> QueueConfig
standardQueue qn =
  QueueConfig
    { queueName = qn,
      queueType = StandardQueue,
      notifyInsert = Nothing,
      fifoIndex = False,
      topicBindings = []
    }

-- | Create an unlogged queue configuration (faster, no WAL, lost on crash).
unloggedQueue :: QueueName -> QueueConfig
unloggedQueue qn =
  (standardQueue qn) {queueType = UnloggedQueue}

-- | Create a partitioned queue configuration.
partitionedQueue :: QueueName -> PartitionConfig -> QueueConfig
partitionedQueue qn pc =
  (standardQueue qn) {queueType = PartitionedQueue pc}

-- | Enable LISTEN/NOTIFY on message insert.
withNotifyInsert :: Maybe Int32 -> QueueConfig -> QueueConfig
withNotifyInsert ms cfg =
  cfg {notifyInsert = Just NotifyConfig {throttleMs = ms}}

-- | Add a FIFO index for strict message ordering.
withFifoIndex :: QueueConfig -> QueueConfig
withFifoIndex cfg = cfg {fifoIndex = True}

-- | Bind a topic pattern for AMQP-style routing.
withTopicBinding :: TopicPattern -> QueueConfig -> QueueConfig
withTopicBinding pat cfg =
  cfg & #topicBindings %~ (++ [pat])

-- JSON wire encodings ---------------------------------------------------------

encodeFields :: [Series] -> Encoding
encodeFields = pairs . mconcat

-- | Encodes as @{"type":"standard"}@, @{"type":"unlogged"}@, or
-- @{"type":"partitioned","partition_interval":…,"retention_interval":…,"premake":…}@.
-- See @docs/design/020-json-wire-encodings.md@.
queueTypeFields :: (KeyValue e kv) => QueueType -> [kv]
queueTypeFields qt = case qt of
  StandardQueue -> ["type" .= ("standard" :: Text)]
  UnloggedQueue -> ["type" .= ("unlogged" :: Text)]
  PartitionedQueue pc -> ("type" .= ("partitioned" :: Text)) : partitionConfigFields pc

-- | JSON wire contract: @docs/design/020-json-wire-encodings.md@.
instance ToJSON QueueType where
  toJSON = object . queueTypeFields
  toEncoding = encodeFields . queueTypeFields

-- | @premake@ is @null@ when creation uses the server default. See
-- @docs/design/020-json-wire-encodings.md@.
partitionConfigFields :: (KeyValue e kv) => PartitionConfig -> [kv]
partitionConfigFields pc =
  [ "partition_interval" .= (pc ^. #partitionInterval),
    "retention_interval" .= (pc ^. #retentionInterval),
    "premake" .= (pc ^. #premake)
  ]

-- | JSON wire contract: @docs/design/020-json-wire-encodings.md@.
instance ToJSON PartitionConfig where
  toJSON = object . partitionConfigFields
  toEncoding = encodeFields . partitionConfigFields

-- | Encodes as the bare strings @"standard"@, @"unlogged"@, @"partitioned"@.
observedQueueTypeText :: ObservedQueueType -> Text
observedQueueTypeText ot = case ot of
  ObservedStandard -> "standard"
  ObservedUnlogged -> "unlogged"
  ObservedPartitioned -> "partitioned"

-- | JSON wire contract: @docs/design/020-json-wire-encodings.md@.
instance ToJSON ObservedQueueType where
  toJSON = toJSON . observedQueueTypeText
  toEncoding = toEncoding . observedQueueTypeText

-- | A tagged object: @"action"@ holds the snake_case constructor name and the
-- remaining keys name the fields. @enabled_notify@'s @throttle_interval_ms@
-- is @null@ when the config declared the pgmq default ('defaultThrottleMs')
-- rather than a number; that is a different fact from declaring @250@.
-- Encode-only. The encoding is a published compatibility surface; see
-- @docs/design/020-json-wire-encodings.md@. Every constructor added later
-- must be encoded by the same rule and pinned in
-- @pgmq-config/test/JsonGoldenSpec.hs@.
reconcileActionFields :: (KeyValue e kv) => ReconcileAction -> [kv]
reconcileActionFields action = case action of
  CreatedQueue q t ->
    ["action" .= tag "created_queue", "queue" .= q, "queue_type" .= t]
  EnabledNotify q ms ->
    ["action" .= tag "enabled_notify", "queue" .= q, "throttle_interval_ms" .= ms]
  CreatedFifoIndex q ->
    ["action" .= tag "created_fifo_index", "queue" .= q]
  BoundTopic q p ->
    ["action" .= tag "bound_topic", "queue" .= q, "pattern" .= p]
  SkippedQueue q ->
    ["action" .= tag "skipped_queue", "queue" .= q]
  SkippedNotify q ->
    ["action" .= tag "skipped_notify", "queue" .= q]
  SkippedFifoIndex q ->
    ["action" .= tag "skipped_fifo_index", "queue" .= q]
  SkippedTopicBinding q p ->
    ["action" .= tag "skipped_topic_binding", "queue" .= q, "pattern" .= p]
  UpdatedNotifyThrottle q observed declared ->
    [ "action" .= tag "updated_notify_throttle",
      "queue" .= q,
      "observed_throttle_interval_ms" .= observed,
      "declared_throttle_interval_ms" .= declared
    ]
  DetectedQueueTypeDrift q declared observed ->
    [ "action" .= tag "detected_queue_type_drift",
      "queue" .= q,
      "declared" .= declared,
      "observed" .= observed
    ]
  DetectedQueueNameCollision q names ->
    ["action" .= tag "detected_queue_name_collision", "queue" .= q, "colliding_names" .= names]
  UnsupportedNotifyOnPartitionedQueue q ->
    ["action" .= tag "unsupported_notify_on_partitioned_queue", "queue" .= q]
  where
    tag :: Text -> Text
    tag = id

-- | JSON wire contract: @docs/design/020-json-wire-encodings.md@.
instance ToJSON ReconcileAction where
  toJSON = object . reconcileActionFields
  toEncoding = encodeFields . reconcileActionFields