packages feed

keiro-pgmq-0.2.0.0: src/Keiro/PGMQ/Job.hs

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE TypeApplications #-}
-- @enqueue@/@enqueueWithDelay@ carry an @IOE :> es@ constraint that the @Pgmq@
-- send operation does not strictly require. It is kept deliberately: it is part
-- of the published @keiro-pgmq@ contract (mirrored in the MasterPlan's
-- Integration Points and depended on by the two consumer migrations), and it
-- keeps the producer signatures uniform with the processor/runner ones. We
-- therefore silence the otherwise-correct redundant-constraint warning here.
{-# OPTIONS_GHC -Wno-redundant-constraints #-}

{- | Layer 2 of @keiro-pgmq@: the typed-'Job' ergonomics built on top of
'Keiro.PGMQ.Runtime'. This is the payoff layer that absorbs the boilerplate two
real apps wrote by hand.

An application declares a 'Job' value bundling a queue ('Keiro.PGMQ.Runtime.QueueRef'),
a payload codec ('Keiro.PGMQ.Codec.JobCodec'), and a 'RetryPolicy'; then writes
a plain domain handler of type @p -> Eff es 'JobOutcome'@ that never touches
shibuya's @Ingested@/@AckDecision@ or PGMQ's wire types. The package provides:

  * 'enqueue' / 'enqueueWithDelay' — producers.
  * 'ensureJobQueue' — idempotent main-queue + DLQ creation.
  * 'jobProcessor' — build a shibuya processor from a 'Job' plus a handler.
  * 'runJobWorkers' — continuous, multi-processor supervised run (the @rei@ cadence).
  * 'runJobOnce' — one-shot drain of up to @n@ messages (the @hospital-capacity@ cadence).

== Delivery and crash semantics

Delivery is at-least-once. A handler must be idempotent because the same message
can be delivered again after a worker crash, a handler exception, or a visibility
timeout expiry. Crash redelivery cadence is the active visibility timeout, not
the 'RetryPolicy' delay; the policy delay applies only to explicit 'Retry' and
'RetryDefault' outcomes. Every visibility-timeout expiry consumes one PGMQ
@read_ct@ attempt, and messages whose read count exceeds 'maxRetries' are
dead-lettered before the handler sees them.

Dead-lettering sends a DLQ row and then deletes the main-queue row, so a crash
between those two statements can leave the message in both places. 'redriveDlq'
has the same at-least-once window in the other direction: it sends the preserved
payload back to the main queue and then deletes the DLQ row.

Transient database errors during PGMQ polling are retried by the adapter, and a
polling failure that exhausts that retry policy is propagated visibly through
shibuya supervision rather than completing the worker silently.
-}
module Keiro.PGMQ.Job (
    -- * Job declaration
    JobOutcome (..),
    RetryDelay (..),
    RetryPolicy (..),
    RetryPolicyConfigError (..),
    mkRetryPolicy,
    defaultRetryPolicy,
    Job (..),
    JobPolling (..),
    JobOrdering (..),
    JobTuning (..),
    JobTuningConfigError (..),
    mkJobTuning,
    defaultJobTuning,
    withOrdering,

    -- * Message metadata
    MessageHeaders (..),

    -- * Producing work
    enqueue,
    enqueueWithDelay,
    enqueueWithHeaders,
    enqueueWithHeadersAndDelay,
    enqueueBatch,
    enqueueBatchWithDelay,
    enqueueBatchWithHeaders,
    enqueueTraced,
    enqueueTracedWithDelay,
    enqueueToGroup,
    enqueueToGroupWithDelay,

    -- * Queue lifecycle
    QueueKind (..),
    PartitionSpec (..),
    QueueProvision (..),
    standardProvision,
    unloggedProvision,
    partitionedProvision,
    withFifoIndexProvision,
    queueProvisionConfigs,
    ensureJobQueue,
    ensureJobQueueWith,
    ensureFifoIndex,
    ensureOrderedJobQueue,

    -- * Consuming work
    JobContext (..),
    jobProcessorWithContext,
    jobProcessor,
    runJobWorkers,
    runJobOnceWithContext,
    runJobOnce,
) where

import Keiro.PGMQ.Codec (JobCodec, JobDecodeError (..), decodeJob, encodeJob)
import Keiro.PGMQ.Runtime (QueueRef (..))
import "aeson" Data.Aeson (Value, object, (.=))
import "base" Control.Exception (Exception, SomeException, throwIO)
import "base" Control.Monad (foldM, void)
import "base" Data.Int (Int32, Int64)
import "effectful-core" Effectful (Eff, IOE, liftIO, (:>))
import "effectful-core" Effectful.Error.Static (Error)
import "effectful-core" Effectful.Exception qualified as EffException
import "effectful-core" Effectful.Reader.Static (Reader, ask)
import "hs-opentelemetry-api" OpenTelemetry.Context.ThreadLocal (getContext)
import "hs-opentelemetry-api" OpenTelemetry.Trace.Core (TracerProvider)
import "pgmq-config" Pgmq.Config.Effectful (ensureQueuesEff)
import "pgmq-config" Pgmq.Config.Types qualified as Config
import "pgmq-effectful" Pgmq.Effectful (
    BatchSendMessage (..),
    BatchSendMessageWithHeaders (..),
    Message (..),
    MessageBody (..),
    MessageHeaders (..),
    MessageId,
    MessageQuery (..),
    Pgmq,
    PgmqRuntimeError,
    ReadMessage (..),
    SendMessage (..),
    SendMessageWithHeaders (..),
    VisibilityTimeoutQuery (..),
    injectTraceContext,
    mergeTraceHeaders,
 )
import "pgmq-effectful" Pgmq.Effectful qualified as Pgmq
import "pgmq-effectful" Pgmq.Effectful.Effect (readGrouped, readGroupedRoundRobin)
import "pgmq-hasql" Pgmq.Hasql.Statements.Types (ReadGrouped (..))
import "shibuya-core" Shibuya.App (
    AppConfig (..),
    AppError,
    AppHandle,
    ProcessorId (..),
    QueueProcessor,
    SupervisionStrategy,
    mkProcessor,
    runApp,
 )
import "shibuya-core" Shibuya.Core.Ack (AckDecision (..), DeadLetterReason (..), RetryDelay (..))
import "shibuya-core" Shibuya.Core.Ingested qualified as Shibuya
import "shibuya-core" Shibuya.Core.Lease (Lease (..))
import "shibuya-core" Shibuya.Core.Types (Attempt (..), Envelope (..))
import "shibuya-core" Shibuya.Telemetry.Effect (Tracing)
import "shibuya-pgmq-adapter" Shibuya.Adapter.Pgmq (
    FifoConfig (..),
    FifoReadStrategy (..),
    PgmqAdapterConfig (..),
    PgmqAdapterEnv,
    PgmqConfigError,
    PollingConfig (..),
    defaultConfig,
    directDeadLetter,
    pgmqAdapter,
 )
import "shibuya-pgmq-adapter" Shibuya.Adapter.Pgmq.Convert (
    mkDlqPayload,
    pgmqMessageToEnvelope,
 )
import "text" Data.Text (Text)
import "time" Data.Time (NominalDiffTime, nominalDiffTimeToSeconds)

-- | What a job handler decides. Never exposes shibuya/PGMQ wire types to the caller.
data JobOutcome
    = -- | Processed successfully; delete the message from the queue.
      Done
    | -- | Leave the message on the queue; redeliver after the delay.
      Retry !RetryDelay
    | -- | Leave the message on the queue; redeliver after the policy's default retry delay.
      RetryDefault
    | -- | Poison message; route to the dead-letter queue when enabled, otherwise archive it, with this reason.
      Dead !Text
    deriving stock (Show)

{- | How a queue retries and dead-letters.

The raw constructor is exported for advanced/manual configuration, but it is
not validated. Prefer 'mkRetryPolicy': @maxRetries <= 0@ dead-letters every
message before the handler runs because PGMQ's @read_ct@ is 1 on first delivery
and the adapter auto-dead-letters when @read_ct > maxRetries@. Negative retry
delays can create immediate redelivery storms.

'maxRetries' is the number of
deliveries PGMQ allows before auto-dead-lettering; 'defaultRetryDelay' is a
convenience default a handler can reach for; 'useDeadLetter' decides whether a
DLQ is created and routed to at all.
-}
data RetryPolicy = RetryPolicy
    { maxRetries :: !Int64
    , defaultRetryDelay :: !RetryDelay
    , useDeadLetter :: !Bool
    }
    deriving stock (Eq, Show)

data RetryPolicyConfigError
    = NonPositiveMaxRetries !Int64
    | NegativeRetryDelay !RetryDelay
    deriving stock (Eq, Show)

mkRetryPolicy :: Int64 -> RetryDelay -> Bool -> Either RetryPolicyConfigError RetryPolicy
mkRetryPolicy maxRetries defaultRetryDelay useDeadLetter
    | maxRetries < 1 = Left (NonPositiveMaxRetries maxRetries)
    | retryDelaySeconds defaultRetryDelay < 0 = Left (NegativeRetryDelay defaultRetryDelay)
    | otherwise =
        Right
            RetryPolicy
                { maxRetries
                , defaultRetryDelay
                , useDeadLetter
                }

-- | Five deliveries, a 60-second default retry delay, and a DLQ enabled.
defaultRetryPolicy :: RetryPolicy
defaultRetryPolicy =
    RetryPolicy
        { maxRetries = 5
        , defaultRetryDelay = RetryDelay 60
        , useDeadLetter = True
        }

data JobPolling
    = -- | Sleep this long between empty polls.
      PollEvery !NominalDiffTime
    | -- | Long-poll inside the database: max seconds to wait, then check interval in milliseconds.
      LongPoll !Int32 !Int32
    deriving stock (Eq, Show)

{- | How a consumer orders deliveries.

'Unordered' is the historical behavior: PGMQ's plain @read@, FIFO only in
selection order (@msg_id@ ascending), with NO per-key delivery-order guarantee
under concurrent workers, retries, or visibility-timeout expiry.

'FifoThroughput' and 'FifoRoundRobin' enable strict per-group ordering via PGMQ
message groups (the reserved @x-pgmq-group@ header). Within one group, messages
are delivered in strict send order; distinct groups proceed in parallel.
'FifoThroughput' fills a batch from the oldest eligible group first (SQS-style,
@read_grouped@); 'FifoRoundRobin' interleaves fairly across groups
(@read_grouped_rr@). Delivery is still at-least-once and there is no
deduplication, so handlers must be idempotent.
-}
data JobOrdering
    = Unordered
    | FifoThroughput
    | FifoRoundRobin
    deriving stock (Eq, Show)

{- | How a consumer reads the queue.

The raw constructor is exported but not validated. Prefer 'mkJobTuning' so
visibility timeouts, batch sizes, and polling intervals are positive.
-}
data JobTuning = JobTuning
    { visibilityTimeout :: !Int32
    , batchSize :: !Int32
    , polling :: !JobPolling
    , ordering :: !JobOrdering
    }
    deriving stock (Eq, Show)

-- | 30 s visibility timeout, batch of 1, 1 s standard polling, unordered reads.
defaultJobTuning :: JobTuning
defaultJobTuning =
    JobTuning
        { visibilityTimeout = 30
        , batchSize = 1
        , polling = PollEvery 1
        , ordering = Unordered
        }

data JobTuningConfigError
    = NonPositiveVisibilityTimeout !Int32
    | NonPositiveBatchSize !Int32
    | NonPositivePollInterval
    deriving stock (Eq, Show)

mkJobTuning :: Int32 -> Int32 -> JobPolling -> Either JobTuningConfigError JobTuning
mkJobTuning visibilityTimeout batchSize polling
    | visibilityTimeout < 1 = Left (NonPositiveVisibilityTimeout visibilityTimeout)
    | batchSize < 1 = Left (NonPositiveBatchSize batchSize)
    | not (validPolling polling) = Left NonPositivePollInterval
    | otherwise = Right JobTuning{visibilityTimeout, batchSize, polling, ordering = Unordered}

{- | Set the FIFO read strategy on an existing tuning, e.g.
@withOrdering FifoThroughput defaultJobTuning@. Every 'JobOrdering' value is
valid, so this is a plain record update rather than a validating constructor.
-}
withOrdering :: JobOrdering -> JobTuning -> JobTuning
withOrdering o tuning = tuning{ordering = o}

validPolling :: JobPolling -> Bool
validPolling (PollEvery interval) = interval > 0
validPolling (LongPoll maxPollSeconds pollIntervalMs) =
    maxPollSeconds > 0 && pollIntervalMs > 0

toPollingConfig :: JobPolling -> PollingConfig
toPollingConfig (PollEvery interval) = StandardPolling interval
toPollingConfig (LongPoll maxPollSeconds pollIntervalMs) = LongPolling maxPollSeconds pollIntervalMs

-- | Map an ordering choice to the shibuya adapter's FIFO read config (worker path).
toFifoConfig :: JobOrdering -> Maybe FifoConfig
toFifoConfig Unordered = Nothing
toFifoConfig FifoThroughput = Just (FifoConfig ThroughputOptimized)
toFifoConfig FifoRoundRobin = Just (FifoConfig RoundRobin)

retryDelaySeconds :: RetryDelay -> NominalDiffTime
retryDelaySeconds (RetryDelay seconds) = seconds

nominalToSeconds :: NominalDiffTime -> Int32
nominalToSeconds dt =
    let seconds :: Double
        seconds = realToFrac (nominalDiffTimeToSeconds dt)
        maxSec :: Double
        maxSec = fromIntegral (maxBound :: Int32)
        minSec :: Double
        minSec = fromIntegral (minBound :: Int32)
        clamped = max minSec (min maxSec seconds)
     in ceiling clamped

{- | A declarative job: a queue, a payload codec, and a retry policy, named for
telemetry. Construct one and pair it with a handler of type
@p -> Eff es 'JobOutcome'@.
-}
data Job p = Job
    { jobName :: !Text
    -- ^ Used as the shibuya 'ProcessorId' and telemetry label.
    , jobQueue :: !QueueRef
    , jobCodec :: !(JobCodec p)
    , jobPolicy :: !RetryPolicy
    }

-- | Per-delivery capabilities handed to context-aware handlers.
data JobContext es = JobContext
    { extendLease :: !(NominalDiffTime -> Eff es ())
    -- ^ Push the message's visibility timeout further into the future.
    , attempt :: !(Maybe Word)
    -- ^ Zero-based delivery attempt; @Just 0@ is the first delivery.
    , headers :: !(Maybe Value)
    {- ^ Drain path: the raw PGMQ message header object (@Just@ when the
    message carried headers, @Nothing@ otherwise). Worker path: always
    @Nothing@, because the shibuya adapter's @Envelope@ does not surface
    arbitrary headers (only the trace context, which shibuya itself uses
    to continue the trace).
    -}
    }

-- | Producer: encode @p@ with the job's codec and send it to the queue, no delay.
enqueue :: (Pgmq :> es, IOE :> es) => Job p -> p -> Eff es MessageId
enqueue job p =
    Pgmq.sendMessage
        SendMessage
            { queueName = job.jobQueue.physicalName
            , messageBody = MessageBody (encodeJob job.jobCodec p)
            , delay = Nothing
            }

{- | Producer with an explicit visibility delay (in seconds, PGMQ's @Delay@ is
@Int32@) before first delivery.
-}
enqueueWithDelay :: (Pgmq :> es, IOE :> es) => Job p -> Int32 -> p -> Eff es MessageId
enqueueWithDelay job d p =
    Pgmq.sendMessage
        SendMessage
            { queueName = job.jobQueue.physicalName
            , messageBody = MessageBody (encodeJob job.jobCodec p)
            , delay = Just d
            }

{- | Producer that attaches caller-supplied message headers (an arbitrary JSON
object) alongside the encoded payload. Headers ride in PGMQ's @headers@ column
and are readable by the consumer (see 'JobContext'\'s @headers@ field on the
drain path).

The headers are passed through verbatim. In particular the reserved FIFO group
key @x-pgmq-group@ is neither reserved, injected, stripped, nor rewritten, so a
caller (or a sibling plan building ordered delivery) may set it freely.
-}
enqueueWithHeaders ::
    (Pgmq :> es, IOE :> es) => Job p -> MessageHeaders -> p -> Eff es MessageId
enqueueWithHeaders job hdrs p =
    Pgmq.sendMessageWithHeaders
        SendMessageWithHeaders
            { queueName = job.jobQueue.physicalName
            , messageBody = MessageBody (encodeJob job.jobCodec p)
            , messageHeaders = hdrs
            , delay = Nothing
            }

{- | 'enqueueWithHeaders' with an explicit visibility delay (in seconds) before
first delivery.
-}
enqueueWithHeadersAndDelay ::
    (Pgmq :> es, IOE :> es) => Job p -> Int32 -> MessageHeaders -> p -> Eff es MessageId
enqueueWithHeadersAndDelay job d hdrs p =
    Pgmq.sendMessageWithHeaders
        SendMessageWithHeaders
            { queueName = job.jobQueue.physicalName
            , messageBody = MessageBody (encodeJob job.jobCodec p)
            , messageHeaders = hdrs
            , delay = Just d
            }

{- | Batch producer: encode and enqueue many payloads in a single database
round-trip, returning one 'MessageId' per payload in order. An empty input
short-circuits to @[]@ and issues no statement.
-}
enqueueBatch :: (Pgmq :> es, IOE :> es) => Job p -> [p] -> Eff es [MessageId]
enqueueBatch _ [] = pure []
enqueueBatch job ps =
    Pgmq.batchSendMessage
        BatchSendMessage
            { queueName = job.jobQueue.physicalName
            , messageBodies = map (MessageBody . encodeJob job.jobCodec) ps
            , delay = Nothing
            }

{- | 'enqueueBatch' with a single visibility delay (in seconds) applied to every
message in the batch.
-}
enqueueBatchWithDelay ::
    (Pgmq :> es, IOE :> es) => Job p -> Int32 -> [p] -> Eff es [MessageId]
enqueueBatchWithDelay _ _ [] = pure []
enqueueBatchWithDelay job d ps =
    Pgmq.batchSendMessage
        BatchSendMessage
            { queueName = job.jobQueue.physicalName
            , messageBodies = map (MessageBody . encodeJob job.jobCodec) ps
            , delay = Just d
            }

{- | Batch producer that attaches a distinct header object to each payload. The
input pairs each payload with its headers so the body and header lists cannot be
desynchronized. An empty input short-circuits to @[]@.
-}
enqueueBatchWithHeaders ::
    (Pgmq :> es, IOE :> es) => Job p -> [(MessageHeaders, p)] -> Eff es [MessageId]
enqueueBatchWithHeaders _ [] = pure []
enqueueBatchWithHeaders job pairs =
    Pgmq.batchSendMessageWithHeaders
        BatchSendMessageWithHeaders
            { queueName = job.jobQueue.physicalName
            , messageBodies = map (MessageBody . encodeJob job.jobCodec . snd) pairs
            , messageHeaders = map fst pairs
            , delay = Nothing
            }

{- | Producer that propagates the current OpenTelemetry trace context onto the
enqueued message so the handler runs inside the same trace. The current
thread-local context is injected to carrier headers via the provider's
configured propagator (W3C @traceparent@ by default) and additively merged onto
@extraHeaders@ — any key already present in @extraHeaders@ wins, so a
caller-set @x-pgmq-group@ survives. Pass @MessageHeaders (object [])@ to inject
only the trace.
-}
enqueueTraced ::
    (Pgmq :> es, IOE :> es) =>
    TracerProvider -> Job p -> MessageHeaders -> p -> Eff es MessageId
enqueueTraced provider job extraHeaders p = do
    ctx <- liftIO getContext
    traceHeaders <- injectTraceContext provider ctx
    let merged = MessageHeaders (mergeTraceHeaders traceHeaders (Just extraHeaders.unMessageHeaders))
    enqueueWithHeaders job merged p

{- | 'enqueueTraced' with an explicit visibility delay (in seconds) before first
delivery.
-}
enqueueTracedWithDelay ::
    (Pgmq :> es, IOE :> es) =>
    TracerProvider -> Job p -> Int32 -> MessageHeaders -> p -> Eff es MessageId
enqueueTracedWithDelay provider job d extraHeaders p = do
    ctx <- liftIO getContext
    traceHeaders <- injectTraceContext provider ctx
    let merged = MessageHeaders (mergeTraceHeaders traceHeaders (Just extraHeaders.unMessageHeaders))
    enqueueWithHeadersAndDelay job d merged p

{- | Enqueue a payload into the FIFO group named by @groupKey@. The group key is
written under the reserved @x-pgmq-group@ JSONB header, which PGMQ's grouped
reads and the shibuya adapter use to order deliveries per group. Consume with an
ordered 'JobTuning' (see 'withOrdering') to honor the order; within one group,
messages are handled in strict send order while distinct groups proceed in
parallel.
-}
enqueueToGroup ::
    (Pgmq :> es, IOE :> es) => Job p -> Text -> p -> Eff es MessageId
enqueueToGroup job groupKey p =
    enqueueWithHeaders job (groupHeader groupKey) p

-- | 'enqueueToGroup' with an explicit first-delivery delay (in seconds).
enqueueToGroupWithDelay ::
    (Pgmq :> es, IOE :> es) => Job p -> Int32 -> Text -> p -> Eff es MessageId
enqueueToGroupWithDelay job d groupKey p =
    enqueueWithHeadersAndDelay job d (groupHeader groupKey) p

-- | The reserved FIFO group header for a group key.
groupHeader :: Text -> MessageHeaders
groupHeader k = MessageHeaders (object ["x-pgmq-group" .= k])

{- | The three PostgreSQL storage shapes a job's main queue can take.

  * 'StandardKind' — a normal write-ahead-logged queue table (today's default).
  * 'UnloggedKind' — an /unlogged/ table: writes skip the WAL (faster) but the
    table is truncated to empty on a database crash. For transient, regenerable
    work.
  * 'PartitionedKind' — storage split across child tables by time or message-id
    range, managed by the PostgreSQL extension @pg_partman@. Requires a
    @pg_partman@-enabled server (see 'partitionedProvision').
-}
data QueueKind
    = StandardKind
    | UnloggedKind
    | PartitionedKind !PartitionSpec
    deriving stock (Eq, Show)

{- | Partition interval + retention interval for a partitioned queue. Both are
PostgreSQL/@pg_partman@ duration or integer strings — e.g. @"daily"@ or
@"10000"@ for the interval, @"7 days"@ or @"100000"@ for the retention.
-}
data PartitionSpec = PartitionSpec
    { partitionInterval :: !Text
    , retentionInterval :: !Text
    }
    deriving stock (Eq, Show)

{- | The provisioning choice for a job's /main/ queue: which storage shape, and
whether to create the FIFO GIN index. The DLQ (when the policy enables one) is
always a plain standard queue with no FIFO index.
-}
data QueueProvision = QueueProvision
    { provisionKind :: !QueueKind
    , provisionFifoIndex :: !Bool
    }
    deriving stock (Eq, Show)

-- | A standard main queue with no FIFO index — exactly today's behavior.
standardProvision :: QueueProvision
standardProvision = QueueProvision{provisionKind = StandardKind, provisionFifoIndex = False}

-- | An unlogged main queue with no FIFO index.
unloggedProvision :: QueueProvision
unloggedProvision = QueueProvision{provisionKind = UnloggedKind, provisionFifoIndex = False}

-- | A partitioned main queue (no FIFO index) with the given interval/retention.
partitionedProvision :: PartitionSpec -> QueueProvision
partitionedProvision spec =
    QueueProvision{provisionKind = PartitionedKind spec, provisionFifoIndex = False}

-- | Turn on FIFO-index creation for a provisioning choice.
withFifoIndexProvision :: QueueProvision -> QueueProvision
withFifoIndexProvision provision = provision{provisionFifoIndex = True}

{- | Pure: the list of @pgmq-config@ 'Config.QueueConfig's that
'ensureJobQueueWith' will reconcile — the main queue first (with its chosen kind
and optional FIFO index), then the DLQ (always a standard queue) when the policy
enables one. Exposed so the partitioned path is testable without a
@pg_partman@-enabled database.
-}
queueProvisionConfigs :: QueueProvision -> Job p -> [Config.QueueConfig]
queueProvisionConfigs provision job =
    mainConfig : dlqConfigs
  where
    mainBase =
        case provision.provisionKind of
            StandardKind -> Config.standardQueue job.jobQueue.physicalName
            UnloggedKind -> Config.unloggedQueue job.jobQueue.physicalName
            PartitionedKind spec ->
                Config.partitionedQueue
                    job.jobQueue.physicalName
                    Config.PartitionConfig
                        { Config.partitionInterval = spec.partitionInterval
                        , Config.retentionInterval = spec.retentionInterval
                        }
    mainConfig
        | provision.provisionFifoIndex = Config.withFifoIndex mainBase
        | otherwise = mainBase
    dlqConfigs
        | job.jobPolicy.useDeadLetter = [Config.standardQueue job.jobQueue.dlqName]
        | otherwise = []

{- | Idempotent: create the job's main queue with the chosen storage kind and
(optionally) its FIFO index, plus the DLQ (always a standard queue) when the
policy uses one. Routes through @pgmq-config@'s additive reconciler, which lists
existing queues first and only creates what is missing, so this is safe to call
at every worker startup.
-}
ensureJobQueueWith :: (Pgmq :> es) => QueueProvision -> Job p -> Eff es ()
ensureJobQueueWith provision job =
    ensureQueuesEff (queueProvisionConfigs provision job)

{- | Idempotent: create the main queue, and the DLQ too when the policy uses
one. Unchanged behavior: @ensureJobQueueWith standardProvision@. Safe to call at
every worker startup.
-}
ensureJobQueue :: (Pgmq :> es) => Job p -> Eff es ()
ensureJobQueue = ensureJobQueueWith standardProvision

{- | Create the FIFO GIN index on the job's /main/ queue's @headers@ column —
the index PGMQ's grouped/ordered reads (@read_grouped@/@read_grouped_rr@) match
against. Idempotent: the index step is always re-applied and the underlying SQL
is @CREATE INDEX IF NOT EXISTS@, so a second call is a harmless no-op. Routing
through @pgmq-config@'s reconciler (which lists existing queues first) means
calling this on an already-provisioned queue does not recreate the queue. This
is the artifact the FIFO ordered-delivery plan
(@docs/plans/77-add-fifo-ordered-delivery-via-message-groups-to-keiro-pgmq.md@)
consumes for ordered jobs.
-}
ensureFifoIndex :: (Pgmq :> es) => Job p -> Eff es ()
ensureFifoIndex job =
    ensureQueuesEff
        [Config.withFifoIndex (Config.standardQueue job.jobQueue.physicalName)]

{- | Provision an ordered job's queue: create the main queue (and the DLQ when
the policy uses one) plus the FIFO GIN index that grouped reads need. Composes
'ensureJobQueue' and 'ensureFifoIndex'; both are idempotent, so this is safe to
call at every startup.
-}
ensureOrderedJobQueue :: (Pgmq :> es) => Job p -> Eff es ()
ensureOrderedJobQueue job = do
    ensureJobQueue job
    ensureFifoIndex job

{- | The PGMQ adapter rejected the config derived from a job's tuning. Job tuning
is validated at construction ('mkJobTuning') and 'adapterConfigFor' derives the
adapter config deterministically, so this indicates an internal inconsistency
rather than a recoverable condition; it is surfaced as an exception.
-}
newtype JobAdapterConfigInvalid = JobAdapterConfigInvalid PgmqConfigError
    deriving stock (Show)
    deriving anyclass (Exception)

{- | Build the shibuya PGMQ adapter config from a job's queue and policy: route
to the DLQ via the adapter's @directDeadLetter@ path when the policy enables it.
-}
adapterConfigFor :: JobTuning -> Job p -> PgmqAdapterConfig
adapterConfigFor tuning job =
    (defaultConfig job.jobQueue.physicalName)
        { visibilityTimeout = tuning.visibilityTimeout
        , batchSize = tuning.batchSize
        , polling = toPollingConfig tuning.polling
        , fifoConfig = toFifoConfig tuning.ordering
        , maxRetries = job.jobPolicy.maxRetries
        , deadLetterConfig =
            if job.jobPolicy.useDeadLetter
                then Just (directDeadLetter job.jobQueue.dlqName True)
                else Nothing
        }

{- | The boilerplate this package absorbs once: decode the raw JSON payload with
the job's codec, run the domain handler, and translate its 'JobOutcome' into a
shibuya 'AckDecision'. A payload the codec rejects is dead-lettered.
-}
wrapHandler ::
    Job p ->
    (JobContext es -> p -> Eff es JobOutcome) ->
    (Shibuya.Message es Value -> Eff es AckDecision)
wrapHandler job handle ingested =
    case decodeJob job.jobCodec ingested.envelope.payload of
        Left (JobPayloadFromFuture _payloadVersion _workerVersion) ->
            pure (AckRetry job.jobPolicy.defaultRetryDelay)
        Left (JobPayloadMalformed err) ->
            pure (AckDeadLetter (InvalidPayload err))
        Right p -> toAck <$> handle (contextFor ingested) p
  where
    contextFor message =
        JobContext
            { extendLease = maybe (\_ -> pure ()) (.leaseExtend) message.lease
            , attempt = fmap (.unAttempt) message.envelope.attempt
            , headers = Nothing
            }

    toAck Done = AckOk
    toAck (Retry d) = AckRetry d
    toAck RetryDefault = AckRetry job.jobPolicy.defaultRetryDelay
    toAck (Dead why) = AckDeadLetter (PoisonPill why)

{- | Build a shibuya processor for a job with explicit tuning and a context-aware
handler. The handler must finish, or call 'extendLease', before
'visibilityTimeout' expires; otherwise PGMQ may redeliver the message
concurrently and each redelivery consumes one retry attempt. After a worker
crash, redelivery happens when the visibility timeout expires; the 'RetryPolicy'
delay only governs explicit 'Retry' and 'RetryDefault' outcomes.
-}
jobProcessorWithContext ::
    ( Pgmq :> es
    , Error PgmqRuntimeError :> es
    , Reader PgmqAdapterEnv :> es
    , IOE :> es
    , Tracing :> es
    ) =>
    JobTuning ->
    Job p ->
    (JobContext es -> p -> Eff es JobOutcome) ->
    Eff es (ProcessorId, QueueProcessor es)
jobProcessorWithContext tuning job handle = do
    env <- ask
    adapter <-
        pgmqAdapter env (adapterConfigFor tuning job)
            >>= either (liftIO . throwIO . JobAdapterConfigInvalid) pure
    pure (ProcessorId job.jobName, mkProcessor adapter (wrapHandler job handle))

{- | Build a shibuya processor for a job using 'defaultJobTuning': a PGMQ adapter
configured from the job's policy, paired with the wrapped handler. Pass the
result to 'runJobWorkers'. The same visibility-timeout and crash-redelivery
rules documented on 'jobProcessorWithContext' apply here.
-}
jobProcessor ::
    ( Pgmq :> es
    , Error PgmqRuntimeError :> es
    , Reader PgmqAdapterEnv :> es
    , IOE :> es
    , Tracing :> es
    ) =>
    Job p ->
    (p -> Eff es JobOutcome) ->
    Eff es (ProcessorId, QueueProcessor es)
jobProcessor job handle =
    jobProcessorWithContext defaultJobTuning job (\_context p -> handle p)

{- | Continuous, multi-processor run (the @rei@ cadence): run a supervised app
over several processors built with 'jobProcessor'. Returns the app handle; the
caller decides whether to block on it. The inbox size is clamped to at least 1.
-}
runJobWorkers ::
    (Pgmq :> es, Reader PgmqAdapterEnv :> es, IOE :> es, Tracing :> es) =>
    SupervisionStrategy ->
    Int ->
    [Eff es (ProcessorId, QueueProcessor es)] ->
    Eff es (Either AppError (AppHandle es))
runJobWorkers strategy inboxSize procs = do
    ps <- sequence procs
    runApp AppConfig{strategy = strategy, inboxSize = max 1 inboxSize} ps

{- | One-shot drain of up to @n@ messages with explicit tuning and a
context-aware handler. This reads directly from PGMQ and returns when the queue
is empty or @n@ messages have been acknowledged/retried/dead-lettered,
whichever comes first.

If a handler throws, the message is left on the main queue and remains invisible
until the active visibility timeout expires; the drain keeps processing the rest
of the batch and does not count that message in the returned total.
-}
runJobOnceWithContext ::
    (Pgmq :> es, IOE :> es, Tracing :> es) =>
    JobTuning ->
    Int ->
    Job p ->
    (JobContext es -> p -> Eff es JobOutcome) ->
    Eff es Int
runJobOnceWithContext tuning n job handle
    | n <= 0 = pure 0
    | otherwise = drain 0
  where
    drain handled
        | handled >= n = pure handled
        | otherwise = do
            let qty = nextBatchSize (n - handled)
            messages <- case tuning.ordering of
                Unordered ->
                    Pgmq.readMessage
                        ReadMessage
                            { queueName = job.jobQueue.physicalName
                            , delay = tuning.visibilityTimeout
                            , batchSize = Just qty
                            , conditional = Nothing
                            }
                FifoThroughput ->
                    readGrouped
                        ReadGrouped
                            { queueName = job.jobQueue.physicalName
                            , visibilityTimeout = tuning.visibilityTimeout
                            , qty = qty
                            }
                FifoRoundRobin ->
                    readGroupedRoundRobin
                        ReadGrouped
                            { queueName = job.jobQueue.physicalName
                            , visibilityTimeout = tuning.visibilityTimeout
                            , qty = qty
                            }
            if null messages
                then pure handled
                else do
                    handledInBatch <- foldM step 0 messages
                    drain (handled + handledInBatch)

    nextBatchSize remaining =
        fromIntegral (min remaining (fromIntegral tuning.batchSize :: Int))

    step count message = do
        disposed <- processMessage message
        pure $
            if disposed
                then count + 1
                else count

    processMessage message
        | message.readCount > job.jobPolicy.maxRetries = do
            ackMessage message (AckDeadLetter MaxRetriesExceeded)
            pure True
        | otherwise =
            case decodeJob job.jobCodec (messagePayload message) of
                Left (JobPayloadFromFuture _payloadVersion _workerVersion) -> do
                    ackMessage message (AckRetry job.jobPolicy.defaultRetryDelay)
                    pure True
                Left (JobPayloadMalformed err) -> do
                    ackMessage message (AckDeadLetter (InvalidPayload err))
                    pure True
                Right p -> do
                    outcome <- EffException.try @SomeException (handle (contextFor message) p)
                    case outcome of
                        Left _handlerException ->
                            pure False
                        Right jobOutcome -> do
                            ackMessage message (outcomeToAck jobOutcome)
                            pure True

    messagePayload = (.payload) . pgmqMessageToEnvelope

    contextFor message =
        let envelope = pgmqMessageToEnvelope message
         in JobContext
                { extendLease = \duration ->
                    void $
                        Pgmq.changeVisibilityTimeout
                            VisibilityTimeoutQuery
                                { queueName = job.jobQueue.physicalName
                                , messageId = message.messageId
                                , visibilityTimeoutOffset = nominalToSeconds duration
                                }
                , attempt = fmap (.unAttempt) envelope.attempt
                , headers = message.headers
                }

    outcomeToAck Done = AckOk
    outcomeToAck (Retry d) = AckRetry d
    outcomeToAck RetryDefault = AckRetry job.jobPolicy.defaultRetryDelay
    outcomeToAck (Dead why) = AckDeadLetter (PoisonPill why)

    ackMessage message AckOk =
        void $
            Pgmq.deleteMessage
                MessageQuery
                    { queueName = job.jobQueue.physicalName
                    , messageId = message.messageId
                    }
    ackMessage message (AckRetry delay) =
        void $
            Pgmq.changeVisibilityTimeout
                VisibilityTimeoutQuery
                    { queueName = job.jobQueue.physicalName
                    , messageId = message.messageId
                    , visibilityTimeoutOffset = nominalToSeconds (retryDelaySeconds delay)
                    }
    ackMessage message (AckDeadLetter reason)
        | job.jobPolicy.useDeadLetter = do
            sendDlq message reason
            void $
                Pgmq.deleteMessage
                    MessageQuery
                        { queueName = job.jobQueue.physicalName
                        , messageId = message.messageId
                        }
        | otherwise =
            void $
                Pgmq.archiveMessage
                    MessageQuery
                        { queueName = job.jobQueue.physicalName
                        , messageId = message.messageId
                        }
    ackMessage message (AckHalt _reason) =
        void $
            Pgmq.changeVisibilityTimeout
                VisibilityTimeoutQuery
                    { queueName = job.jobQueue.physicalName
                    , messageId = message.messageId
                    , visibilityTimeoutOffset = 3600
                    }

    sendDlq message reason =
        case message.headers of
            Just headers ->
                void $
                    Pgmq.sendMessageWithHeaders
                        SendMessageWithHeaders
                            { queueName = job.jobQueue.dlqName
                            , messageBody = mkDlqPayload message reason True
                            , messageHeaders = MessageHeaders headers
                            , delay = Nothing
                            }
            Nothing ->
                void $
                    Pgmq.sendMessage
                        SendMessage
                            { queueName = job.jobQueue.dlqName
                            , messageBody = mkDlqPayload message reason True
                            , delay = Nothing
                            }

{- | One-shot drain of up to @n@ messages (the @hospital-capacity@ cadence):
read directly from PGMQ with 'defaultJobTuning', run the handler on each
available message, and return promptly when the queue is empty.
-}
runJobOnce ::
    (Pgmq :> es, IOE :> es, Tracing :> es) =>
    Int ->
    Job p ->
    (p -> Eff es JobOutcome) ->
    Eff es ()
runJobOnce n job handle =
    void $
        runJobOnceWithContext
            defaultJobTuning
            n
            job
            (\_context p -> handle p)