packages feed

keiro-pgmq (empty) → 0.2.0.0

raw patch · 9 files changed

+2710/−0 lines, 9 filesdep +aesondep +basedep +effectful-core

Dependencies added: aeson, base, effectful-core, hasql, hasql-pool, hs-opentelemetry-api, hs-opentelemetry-propagator-w3c, hs-opentelemetry-sdk, hspec, keiro-core, keiro-pgmq, keiro-test-support, pgmq-config, pgmq-core, pgmq-effectful, pgmq-hasql, pgmq-migration, shibuya-core, shibuya-pgmq-adapter, streamly-core, text, time

Files

+ CHANGELOG.md view
@@ -0,0 +1,40 @@+# Changelog++All notable changes to `keiro-pgmq` are recorded here. The format follows+[Keep a Changelog](https://keepachangelog.com/), and the package follows the+[Haskell Package Versioning Policy](https://pvp.haskell.org/).++## [Unreleased]++_No unreleased changes._++## 0.2.0.0 — 2026-07-13++No user-facing changes. `keiro-pgmq` is released at 0.2.0.0 to stay in lockstep+with the rest of the Keiro package set, and is rebuilt against `keiro-core`+0.2.0.0. Consumers should note that the stricter event-stream validation and the+relocation of Keiro's framework tables into the dedicated `keiro` PostgreSQL+schema — both described in the `keiro-core` and `keiro` changelogs — apply to any+application that wires this package's workqueue and dispatch workers.++## 0.1.0.0 — 2026-07-05++Initial Hackage release.++### New Features++- Added typed PGMQ job definitions, payload codecs, runtime workers, job worker+  context, retry and dead-letter policies, and direct job draining.+- Added queue provisioning, FIFO ordered delivery via message groups, producer+  headers, batch enqueue support, queue metrics, archive/retention APIs, DLQ+  inspection, and redrive helpers.+- Added trace propagation through PGMQ job producer and worker paths.++### Bug Fixes++- Classified job decode failures, validated retry tuning, disambiguated long+  queue names, and isolated integration-test databases.++### Other Changes++- Tightened the internal `keiro-core` dependency bound to `^>=0.1.0.0`.
+ keiro-pgmq.cabal view
@@ -0,0 +1,97 @@+cabal-version:   3.0+name:            keiro-pgmq+version:         0.2.0.0+synopsis:        PostgreSQL job-queue (PGMQ) integration for Keiro+description:+  A typed background-job queue for Keiro applications on top of PGMQ (the+  PostgreSQL-native message queue) and shibuya (a Broadway-style worker+  framework). Declare a @Job@ value bundling a queue, a payload codec, and a+  retry/dead-letter policy, then write a plain domain handler of type+  @p -> Eff es JobOutcome@.++license:         BSD-3-Clause+author:          Nadeem Bitar+maintainer:      nadeem@gmail.com+copyright:       2026 Nadeem Bitar+category:        Control+homepage:        https://github.com/shinzui/keiro#readme+bug-reports:     https://github.com/shinzui/keiro/issues+build-type:      Simple+tested-with:     GHC >=9.12 && <9.13+extra-doc-files: CHANGELOG.md++source-repository head+  type:     git+  location: https://github.com/shinzui/keiro.git++common warnings+  ghc-options:+    -Wall -Wcompat -Widentities -Wincomplete-record-updates+    -Wincomplete-uni-patterns -Wpartial-fields -Wredundant-constraints++common shared+  default-language:   GHC2024+  default-extensions:+    DeriveAnyClass+    DuplicateRecordFields+    MultilineStrings+    OverloadedLabels+    OverloadedRecordDot+    OverloadedStrings+    PackageImports++library+  import:          warnings, shared+  exposed-modules:+    Keiro.PGMQ+    Keiro.PGMQ.Codec+    Keiro.PGMQ.Dlq+    Keiro.PGMQ.Job+    Keiro.PGMQ.Metrics+    Keiro.PGMQ.Runtime++  hs-source-dirs:  src+  build-depends:+    , aeson                 >=2.2+    , base                  >=4.21     && <5+    , effectful-core        >=2.6+    , hasql                 >=1.10+    , hasql-pool            >=1.2+    , hs-opentelemetry-api  >=1.0      && <1.1+    , keiro-core            ^>=0.2.0.0+    , pgmq-config           >=0.3      && <0.4+    , pgmq-core             >=0.3      && <0.4+    , pgmq-effectful        >=0.3      && <0.4+    , pgmq-hasql            >=0.3      && <0.4+    , shibuya-core          >=0.8.0.1  && <0.9+    , shibuya-pgmq-adapter  >=0.11     && <0.12+    , streamly-core         >=0.3+    , text                  >=2.1+    , time                  >=1.12++test-suite keiro-pgmq-test+  import:         warnings, shared+  type:           exitcode-stdio-1.0+  hs-source-dirs: test+  main-is:        Main.hs+  ghc-options:    -threaded -rtsopts -with-rtsopts=-N+  build-depends:+    , aeson                            >=2.2+    , base                             >=4.21     && <5+    , effectful-core                   >=2.6+    , hasql                            >=1.10+    , hasql-pool                       >=1.2+    , hs-opentelemetry-api             >=1.0      && <1.1+    , hs-opentelemetry-propagator-w3c  >=1.0      && <1.1+    , hs-opentelemetry-sdk             >=1.0      && <1.1+    , hspec                            >=2.11+    , keiro-core                       ^>=0.2.0.0+    , keiro-pgmq+    , keiro-test-support+    , pgmq-config                      >=0.3      && <0.4+    , pgmq-core                        >=0.3      && <0.4+    , pgmq-effectful                   >=0.3      && <0.4+    , pgmq-migration                   >=0.3      && <0.4+    , shibuya-core                     >=0.8.0.1  && <0.9+    , shibuya-pgmq-adapter             >=0.11     && <0.12+    , text                             >=2.1
+ src/Keiro/PGMQ.hs view
@@ -0,0 +1,22 @@+{- | Umbrella module for @keiro-pgmq@: a typed background-job queue for Keiro+applications on top of PGMQ and shibuya.++Re-exports the whole public surface — the transport-agnostic runtime+('Keiro.PGMQ.Runtime'), the payload codecs ('Keiro.PGMQ.Codec'), and the typed+'Keiro.PGMQ.Job.Job' ergonomics ('Keiro.PGMQ.Job'), plus DLQ operations+('Keiro.PGMQ.Dlq') and the typed queue-metrics surface ('Keiro.PGMQ.Metrics').+Import this one module to get everything.+-}+module Keiro.PGMQ (+    module Keiro.PGMQ.Runtime,+    module Keiro.PGMQ.Codec,+    module Keiro.PGMQ.Job,+    module Keiro.PGMQ.Dlq,+    module Keiro.PGMQ.Metrics,+) where++import Keiro.PGMQ.Codec+import Keiro.PGMQ.Dlq+import Keiro.PGMQ.Job+import Keiro.PGMQ.Metrics+import Keiro.PGMQ.Runtime
+ src/Keiro/PGMQ/Codec.hs view
@@ -0,0 +1,130 @@+{- | How a job payload is turned into PGMQ JSON and back.++PGMQ stores message bodies as JSON ('Data.Aeson.Value'). A 'JobCodec' is the+adapter between a domain payload type @p@ and that JSON. Two are provided:++  * 'aesonJobCodec' - raw @aeson@ encode/decode. A drop-in match for apps that+    already use @ToJSON@/@FromJSON@ payloads, so migrating to @keiro-pgmq@ is+    mechanical.+  * 'keiroJobCodec' - the versioned upgrade. It bridges keiro's+    'Keiro.Codec.Codec' by wrapping payloads in a @{ "v": <version>, "t":+    <event-type>, "data": <payload> }@ envelope and replaying the codec's+    upcaster chain on decode, giving job payloads the same schema-evolution+    story event streams have.++When raising a 'keiroJobCodec' schema version, deploy upgraded workers before+upgraded producers. A worker that sees an envelope from a future schema version+returns 'JobPayloadFromFuture', and the job runner retries it after the queue's+default retry delay so a rolling deploy can complete. Those retries still+consume delivery attempts, so size @maxRetries * defaultRetryDelay@ to cover+the deploy window.++Do not switch a non-empty queue directly from 'aesonJobCodec' to+'keiroJobCodec': the wire shape changes from the bare payload to the+@{"v","t","data"}@ envelope. Drain the queue first or use a transitional codec that+accepts both shapes; otherwise old in-flight messages are malformed and will be+dead-lettered.+-}+module Keiro.PGMQ.Codec (+    JobCodec (..),+    JobDecodeError (..),+    mkJobCodec,+    aesonJobCodec,+    keiroJobCodec,+) where++import "aeson" Data.Aeson (FromJSON, ToJSON, Value, object, parseJSON, toJSON, withObject, (.:), (.:?), (.=))+import "aeson" Data.Aeson.Types (parseEither)+import "base" Data.Bifunctor (first)+import "base" Data.List.NonEmpty qualified as NonEmpty+import "keiro-core" Keiro.Codec (Codec, EventType (..))+import "keiro-core" Keiro.Codec qualified as Codec+import "text" Data.Text (Text)+import "text" Data.Text qualified as Text++-- | Why a job payload could not be decoded.+data JobDecodeError+    = -- | The payload is malformed for this codec: poison, dead-letter it.+      JobPayloadMalformed !Text+    | {- | The payload was written by a newer schema version than this worker knows.+      Carries payload version, then this codec's version. This is transient+      during a rolling deploy: retry it, do not dead-letter it.+      -}+      JobPayloadFromFuture !Int !Int+    deriving stock (Eq, Show)++-- | How a job payload is turned into PGMQ JSON and back.+data JobCodec p = JobCodec+    { encodeJob :: p -> Value+    , decodeJob :: Value -> Either JobDecodeError p+    }++-- | Build a 'JobCodec' from an encoder and a legacy text-returning decoder.+mkJobCodec :: (p -> Value) -> (Value -> Either Text p) -> JobCodec p+mkJobCodec encode decode =+    JobCodec+        { encodeJob = encode+        , decodeJob = first JobPayloadMalformed . decode+        }++{- | The default codec: raw @aeson@. Use this when the payload type already has+@ToJSON@/@FromJSON@ instances and you do not need versioned schema evolution.+-}+aesonJobCodec :: (ToJSON p, FromJSON p) => JobCodec p+aesonJobCodec = mkJobCodec toJSON (first Text.pack . parseEither parseJSON)++{- | Versioned bridge to keiro's 'Keiro.Codec.Codec'. On encode it produces+@{ "v": <schemaVersion>, "t": <event-type>, "data": <encode codec p> }@; on+decode it reads the version and event-type tag, runs the codec's upcaster chain+up to the current version via 'Keiro.Codec.migrateToCurrent', then runs the+codec's current decoder. Legacy envelopes without @"t"@ still decode for+single-event codecs. Codec migration faults are surfaced as+'JobPayloadMalformed' (via 'show' of the 'Keiro.Codec.CodecError'), except for a+future envelope version, which surfaces as 'JobPayloadFromFuture'.+-}+keiroJobCodec :: Codec p -> JobCodec p+keiroJobCodec codec =+    JobCodec+        { encodeJob = \p ->+            let EventType tag = Codec.eventType codec p+             in object+                    [ "v" .= Codec.schemaVersion codec+                    , "t" .= tag+                    , "data" .= Codec.encode codec p+                    ]+        , decodeJob = \value -> do+            (version, tag, dataValue) <- parseEnvelope value+            selectedType <- resolveEventType codec tag+            let currentVersion = Codec.schemaVersion codec+            if version > currentVersion+                then Left (JobPayloadFromFuture version currentVersion)+                else pure ()+            migrated <-+                first (JobPayloadMalformed . Text.pack . show) $+                    Codec.migrateToCurrent codec selectedType version dataValue+            first JobPayloadMalformed (Codec.decode codec selectedType migrated)+        }++-- | Parse the @{ "v", "t", "data" }@ envelope, surfacing aeson failures as malformed payloads.+parseEnvelope :: Value -> Either JobDecodeError (Int, Maybe EventType, Value)+parseEnvelope =+    first (JobPayloadMalformed . Text.pack) . parseEither parser+  where+    parser = withObject "Keiro.PGMQ.Codec envelope" $ \o -> do+        version <- o .: "v"+        tag <- fmap EventType <$> o .:? "t"+        dataValue <- o .: "data"+        pure (version, tag, dataValue)++resolveEventType :: Codec p -> Maybe EventType -> Either JobDecodeError EventType+resolveEventType _ (Just tag) = Right tag+resolveEventType codec Nothing =+    case NonEmpty.toList (Codec.eventTypes codec) of+        [tag] -> Right tag+        tags ->+            Left+                . JobPayloadMalformed+                $ "missing event-type tag in multi-event keiro job envelope; expected one of "+                    <> Text.intercalate ", " (fmap eventTypeText tags)+  where+    eventTypeText (EventType tag) = tag
+ src/Keiro/PGMQ/Dlq.hs view
@@ -0,0 +1,244 @@+{-# LANGUAGE BlockArguments #-}+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE LambdaCase #-}+{-# OPTIONS_GHC -Wno-redundant-constraints #-}++{- | Dead-letter queue inspection and redrive helpers.++The PGMQ adapter writes DLQ rows as a shibuya wrapper whose required keys are+@original_message@ and @dead_letter_reason@. Keiro's worker path always includes+metadata as well: @original_message_id@, @original_enqueued_at@, @last_read_at@,+@read_count@, and @original_headers@. These helpers parse the required keys and+treat metadata as optional so operators can still inspect legacy or hand-written+rows.++PGMQ does not expire DLQ rows by itself. The retention model is "archive-then-+purge": 'archiveDlq' retains dead letters by moving them into the archive table+@pgmq.a_<dlq>@ (preserving @enqueued_at@ / @read_ct@ and stamping @archived_at@)+for audit, while 'purgeDlq' deletes them permanently. An operator who needs an+audit trail runs 'archiveDlq' (retain) and may then 'purgeDlq' (clear the active+table); an operator who does not keeps using 'purgeDlq' alone. Either way, alert+on the DLQ's depth via 'Keiro.PGMQ.Metrics.jobDlqMetrics'.++Redrive is at-least-once: a crash after sending the original payload back to the+main queue but before deleting the DLQ row leaves the payload in both places.+Handlers must therefore be idempotent.+-}+module Keiro.PGMQ.Dlq (+    DlqEntry (..),+    readDlq,+    redriveDlq,+    purgeDlq,+    archiveDlq,+    archiveDlqEntry,+) where++import Keiro.PGMQ.Codec (JobDecodeError (..), decodeJob)+import Keiro.PGMQ.Job (Job (..))+import Keiro.PGMQ.Runtime (QueueRef (..))+import "aeson" Data.Aeson (Value (..), withObject, (.:), (.:?))+import "aeson" Data.Aeson.Types (Parser, parseEither)+import "base" Control.Monad (foldM, void)+import "base" Data.Foldable (toList)+import "base" Data.Int (Int32, Int64)+import "effectful-core" Effectful (Eff, IOE, (:>))+import "pgmq-effectful" Pgmq.Effectful (+    Message (..),+    MessageBody (..),+    MessageId,+    MessageQuery (..),+    Pgmq,+    ReadMessage (..),+    SendMessage (..),+ )+import "pgmq-effectful" Pgmq.Effectful qualified as Pgmq+import "text" Data.Text (Text)+import "text" Data.Text qualified as Text+import "time" Data.Time (UTCTime)++-- | A decoded dead-letter entry: shibuya's DLQ wrapper, unwrapped.+data DlqEntry p = DlqEntry+    { dlqMessageId :: !MessageId+    -- ^ The DLQ row's own PGMQ message id.+    , reason :: !Text+    -- ^ @poison_pill: ...@, @invalid_payload: ...@, or @max_retries_exceeded@.+    , originalPayload :: !(Either JobDecodeError p)+    -- ^ The preserved @original_message@ decoded with the job's codec.+    , originalMessageId :: !(Maybe Int64)+    , originalEnqueuedAt :: !(Maybe UTCTime)+    , readCount :: !(Maybe Int64)+    , rawBody :: !Value+    -- ^ Full DLQ wrapper for forensics.+    }+    deriving stock (Show)++data DlqEnvelope = DlqEnvelope+    { originalMessage :: !Value+    , deadLetterReason :: !Text+    , envelopeOriginalMessageId :: !(Maybe Int64)+    , envelopeOriginalEnqueuedAt :: !(Maybe UTCTime)+    , envelopeReadCount :: !(Maybe Int64)+    }++parseDlqEnvelope :: Value -> Either Text DlqEnvelope+parseDlqEnvelope =+    firstText . parseEither parser+  where+    parser :: Value -> Parser DlqEnvelope+    parser =+        withObject "DLQ payload" \obj -> do+            originalMessage <- obj .: "original_message"+            deadLetterReason <- obj .: "dead_letter_reason"+            envelopeOriginalMessageId <- obj .:? "original_message_id"+            envelopeOriginalEnqueuedAt <- obj .:? "original_enqueued_at"+            envelopeReadCount <- obj .:? "read_count"+            pure+                DlqEnvelope+                    { originalMessage+                    , deadLetterReason+                    , envelopeOriginalMessageId+                    , envelopeOriginalEnqueuedAt+                    , envelopeReadCount+                    }++    firstText = \case+        Left err -> Left (Text.pack err)+        Right value -> Right value++{- | Read and decode up to @n@ DLQ entries. The read uses a 30 second visibility+timeout so concurrent inspections do not immediately see the same rows.+-}+readDlq :: (Pgmq :> es, IOE :> es) => Job p -> Int32 -> Eff es [DlqEntry p]+readDlq job n+    | n <= 0 = pure []+    | otherwise = do+        messages <-+            Pgmq.readMessage+                ReadMessage+                    { queueName = job.jobQueue.dlqName+                    , delay = 30+                    , batchSize = Just n+                    , conditional = Nothing+                    }+        pure (fmap (toEntry job) (toList messages))++toEntry :: Job p -> Message -> DlqEntry p+toEntry job message =+    let body = unMessageBody message.body+     in case parseDlqEnvelope body of+            Left err ->+                DlqEntry+                    { dlqMessageId = message.messageId+                    , reason = "malformed_dlq_payload: " <> err+                    , originalPayload = Left (JobPayloadMalformed err)+                    , originalMessageId = Nothing+                    , originalEnqueuedAt = Nothing+                    , readCount = Nothing+                    , rawBody = body+                    }+            Right envelope ->+                DlqEntry+                    { dlqMessageId = message.messageId+                    , reason = envelope.deadLetterReason+                    , originalPayload = decodeJob job.jobCodec envelope.originalMessage+                    , originalMessageId = envelope.envelopeOriginalMessageId+                    , originalEnqueuedAt = envelope.envelopeOriginalEnqueuedAt+                    , readCount = envelope.envelopeReadCount+                    , rawBody = body+                    }++{- | Move up to @n@ DLQ rows back to the main queue. Redriven messages start a+fresh PGMQ @read_ct@ on the main queue. Malformed DLQ wrappers are left in the+DLQ for inspection.+-}+redriveDlq :: (Pgmq :> es, IOE :> es) => Job p -> Int -> Eff es Int+redriveDlq job n+    | n <= 0 = pure 0+    | otherwise = loop 0+  where+    loop moved+        | moved >= n = pure moved+        | otherwise = do+            messages <-+                Pgmq.readMessage+                    ReadMessage+                        { queueName = job.jobQueue.dlqName+                        , delay = 30+                        , batchSize = Just (fromIntegral (min 100 (n - moved)))+                        , conditional = Nothing+                        }+            if null messages+                then pure moved+                else do+                    movedInBatch <- foldM redriveOne 0 messages+                    if movedInBatch == 0+                        then pure moved+                        else loop (moved + movedInBatch)++    redriveOne count message =+        case parseDlqEnvelope (unMessageBody message.body) of+            Left _err ->+                pure count+            Right envelope -> do+                _ <-+                    Pgmq.sendMessage+                        SendMessage+                            { queueName = job.jobQueue.physicalName+                            , messageBody = MessageBody envelope.originalMessage+                            , delay = Nothing+                            }+                void $+                    Pgmq.deleteMessage+                        MessageQuery+                            { queueName = job.jobQueue.dlqName+                            , messageId = message.messageId+                            }+                pure (count + 1)++-- | Delete all rows currently in the DLQ.+purgeDlq :: (Pgmq :> es, IOE :> es) => Job p -> Eff es ()+purgeDlq job =+    void (Pgmq.deleteAllMessagesFromQueue job.jobQueue.dlqName)++{- | Archive (retain) up to @n@ DLQ rows: move each out of the active DLQ table+@pgmq.q_<dlq>@ into the archive table @pgmq.a_<dlq>@, preserving @enqueued_at@ /+@read_ct@ and stamping @archived_at@. Returns the number archived. This is the+audit-retention counterpart to the delete-only 'purgeDlq'. At-most-once per row+per call; a crash before archiving leaves the row in the active DLQ for a re-run.+-}+archiveDlq :: (Pgmq :> es, IOE :> es) => Job p -> Int -> Eff es Int+archiveDlq job n+    | n <= 0 = pure 0+    | otherwise = loop 0+  where+    loop archived+        | archived >= n = pure archived+        | otherwise = do+            messages <-+                Pgmq.readMessage+                    ReadMessage+                        { queueName = job.jobQueue.dlqName+                        , delay = 30+                        , batchSize = Just (fromIntegral (min 100 (n - archived)))+                        , conditional = Nothing+                        }+            if null messages+                then pure archived+                else do+                    archivedInBatch <- foldM archiveOne 0 messages+                    if archivedInBatch == 0+                        then pure archived+                        else loop (archived + archivedInBatch)++    archiveOne count message = do+        moved <- archiveDlqEntry job message.messageId+        pure (if moved then count + 1 else count)++-- | Archive one specific DLQ row by message id. 'True' if a row was moved.+archiveDlqEntry :: (Pgmq :> es, IOE :> es) => Job p -> MessageId -> Eff es Bool+archiveDlqEntry job msgId =+    Pgmq.archiveMessage+        MessageQuery+            { queueName = job.jobQueue.dlqName+            , messageId = msgId+            }
+ src/Keiro/PGMQ/Job.hs view
@@ -0,0 +1,920 @@+{-# 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)
+ src/Keiro/PGMQ/Metrics.hs view
@@ -0,0 +1,44 @@+{-# LANGUAGE DataKinds #-}++{- | Typed, 'Job'-keyed queue metrics for @keiro-pgmq@.++PGMQ stores each queue as a table @pgmq.q_<name>@; its @metrics()@ function reports+depth and message age. These helpers fetch that 'QueueMetrics' for a job's MAIN+queue and its DEAD-LETTER queue without the caller deriving any physical name.++Use 'jobDlqMetrics' (its 'queueLength') for the depth alerting that+'Keiro.PGMQ.Dlq' recommends; pair it with 'Keiro.PGMQ.Dlq.archiveDlq' /+'Keiro.PGMQ.Dlq.purgeDlq' for retention.+-}+module Keiro.PGMQ.Metrics (+    QueueMetrics (..),+    jobQueueMetrics,+    jobDlqMetrics,+    queueDepth,+    allJobMetrics,+) where++import Keiro.PGMQ.Job (Job (..))+import Keiro.PGMQ.Runtime (QueueRef (..))+import "base" Data.Int (Int64)+import "effectful-core" Effectful (Eff, (:>))+import "pgmq-effectful" Pgmq.Effectful (Pgmq, QueueMetrics (..))+import "pgmq-effectful" Pgmq.Effectful qualified as Pgmq++-- | The metrics for a job's MAIN queue (depth, visible depth, oldest/newest age, throughput).+jobQueueMetrics :: (Pgmq :> es) => Job p -> Eff es QueueMetrics+jobQueueMetrics job = Pgmq.queueMetrics job.jobQueue.physicalName++-- | The metrics for a job's DEAD-LETTER queue. Use its 'queueLength' for depth alerting.+jobDlqMetrics :: (Pgmq :> es) => Job p -> Eff es QueueMetrics+jobDlqMetrics job = Pgmq.queueMetrics job.jobQueue.dlqName++-- | The main queue's immediately-readable depth (PGMQ 'queueVisibleLength'): "work waiting".+queueDepth :: (Pgmq :> es) => Job p -> Eff es Int64+queueDepth job = do+    metrics <- jobQueueMetrics job+    pure metrics.queueVisibleLength++-- | Every queue's metrics (passthrough over 'Pgmq.Effectful.allQueueMetrics'); not Job-keyed.+allJobMetrics :: (Pgmq :> es) => Eff es [QueueMetrics]+allJobMetrics = Pgmq.allQueueMetrics
+ src/Keiro/PGMQ/Runtime.hs view
@@ -0,0 +1,232 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE TypeApplications #-}++{- | Layer 1 of @keiro-pgmq@: transport-agnostic plumbing shared by every PGMQ+integration (and, in the future, by case B — PGMQ as an integration-event+transport). This layer owns the two things every PGMQ user repeats:++  1. Turning an arbitrary logical queue name into a PGMQ-legal physical name+     plus a matching dead-letter-queue name ('QueueRef' / 'queueRef').+  2. Running the @Pgmq : Tracing : Error PgmqRuntimeError : IOE@ effect stack+     against a connection pool with an optional OpenTelemetry tracer+     ('JobRuntime' / 'withJobRuntime' / 'runJobEff').++Nothing here knows about jobs, codecs, or retry policies — that is layer 2+('Keiro.PGMQ.Job').+-}+module Keiro.PGMQ.Runtime (+    -- * Queue-name derivation+    QueueRef (..),+    queueRef,++    -- * Runtime handle and runners+    JobRuntime (..),+    withJobRuntime,+    runJobEff,++    -- * Re-exports+    PgmqRuntimeError,+) where++import "base" Control.Exception (bracket)+import "base" Data.Bits (xor)+import "base" Data.Char (ord)+import "base" Data.Word (Word64)+import "base" Numeric (showHex)+import "effectful-core" Effectful (Eff, IOE, runEff)+import "effectful-core" Effectful.Error.Static (Error, runErrorNoCallStack)+import "effectful-core" Effectful.Reader.Static (Reader, runReader)+import "hasql" Hasql.Connection.Settings qualified as Conn+import "hasql-pool" Hasql.Pool (Pool)+import "hasql-pool" Hasql.Pool qualified as Pool+import "hasql-pool" Hasql.Pool.Config qualified as Pool.Config+import "pgmq-core" Pgmq.Types (QueueName)+import "pgmq-core" Pgmq.Types qualified as Pgmq+import "pgmq-effectful" Pgmq.Effectful (Pgmq, PgmqRuntimeError, runPgmq, runPgmqTraced)+import "shibuya-core" Shibuya.Telemetry.Effect (Tracer, Tracing, runTracing, runTracingNoop)+import "shibuya-pgmq-adapter" Shibuya.Adapter.Pgmq (PgmqAdapterEnv, mkPgmqAdapterEnv)+import "text" Data.Text (Text)+import "text" Data.Text qualified as Text++{- | A logical queue identity plus the PGMQ-legal physical names derived from+it. PGMQ caps queue names at 47 characters and rejects dots and other+non-@[a-z0-9_]@ characters, so a caller-facing logical name (which may contain+dots, e.g. @"hospital_capacity.reservation_work"@) must be sanitized once,+centrally, into a valid physical name plus a @"_dlq"@-suffixed dead-letter name.+-}+data QueueRef = QueueRef+    { logicalName :: !Text+    -- ^ Caller-facing name; may contain dots or otherwise-illegal characters.+    , physicalName :: !QueueName+    -- ^ Derived, PGMQ-valid name used for the main queue.+    , dlqName :: !QueueName+    -- ^ Derived @"<physical>_dlq"@ name used for the dead-letter queue.+    }+    deriving stock (Eq, Show)++{- | Derive a 'QueueRef' from a logical name. Total: it sanitizes rather than+failing. It lower-cases, replaces every character that is not @[a-z0-9_]@ with+@'_'@, collapses repeated underscores (also trimming leading/trailing ones),+and guarantees a leading letter.++Short sanitized names are used byte-for-byte unless they end in @"_dlq"@.+Sanitization equivalence is intentional: @"a.b"@ and @"a_b"@ name the same+queue, so distinct logical queues must differ after lower-casing and replacing+illegal characters with underscores.++When the sanitized base exceeds 43 characters, or the base ends in @"_dlq"@,+the physical main-queue name is @<first 26 chars>_<16 hex chars>@ where the hex+suffix is FNV-1a-64 over the full original logical name. This keeps the derived+DLQ name inside PGMQ's 47-character ceiling and establishes the invariant that+physical main-queue names never end in @"_dlq"@ while derived DLQ names always+do.++Migration note: pre-existing deployments whose sanitized logical name exceeded+43 characters, or ended in @"_dlq"@, derive a different physical queue after+this change. Messages in the old physical queue are not lost, but new workers+will not read them. Drain the old queue before upgrading or temporarily run a+worker against the old physical name.++For example, @queueRef "hospital_capacity.reservation_work"@ yields+@physicalName == "hospital_capacity_reservation_work"@ and+@dlqName == "hospital_capacity_reservation_work_dlq"@.+-}+queueRef :: Text -> QueueRef+queueRef logical =+    QueueRef+        { logicalName = logical+        , physicalName = forceQueueName base+        , dlqName = forceQueueName (base <> "_dlq")+        }+  where+    base = physicalBase logical++-- | Reserve 4 characters for the @"_dlq"@ suffix below the 47-char ceiling.+maxBaseLength :: Int+maxBaseLength = 43++hashedPrefixLength :: Int+hashedPrefixLength = 26++physicalBase :: Text -> Text+physicalBase logical =+    if Text.length base <= maxBaseLength && not ("_dlq" `Text.isSuffixOf` base)+        then base+        else hashedBase logical base+  where+    base = sanitize logical++sanitize :: Text -> Text+sanitize =+    ensureLeadingLetter+        . collapseUnderscores+        . Text.map toLegal+        . Text.toLower+  where+    toLegal c+        | (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' = c+        | otherwise = '_'++{- | Replace runs of underscores with a single underscore and drop leading and+trailing underscores.+-}+collapseUnderscores :: Text -> Text+collapseUnderscores =+    Text.intercalate "_" . filter (not . Text.null) . Text.splitOn "_"++{- | Guarantee the name begins with an ASCII letter (PGMQ-derived table names+must start with a letter). Falls back to a bare @"q"@ for an empty result.+-}+ensureLeadingLetter :: Text -> Text+ensureLeadingLetter t =+    case Text.uncons t of+        Nothing -> "q"+        Just (c, _)+            | c >= 'a' && c <= 'z' -> t+            | otherwise -> Text.cons 'q' t++hashedBase :: Text -> Text -> Text+hashedBase logical base =+    prefix <> "_" <> fnv1a64Hex logical+  where+    trimmedPrefix = Text.dropWhileEnd (== '_') (Text.take hashedPrefixLength base)+    prefix+        | Text.null trimmedPrefix = "q"+        | otherwise = trimmedPrefix++fnv1a64Hex :: Text -> Text+fnv1a64Hex logical =+    Text.pack (replicate (16 - length rendered) '0' <> rendered)+  where+    rendered = showHex (Text.foldl' step offset logical) ""+    offset :: Word64+    offset = 0xcbf29ce484222325+    prime :: Word64+    prime = 0x100000001b3+    step hash c = (hash `xor` fromIntegral (ord c)) * prime++{- | Build a 'QueueName' from an already-sanitized 'Text'. The sanitizer should+never produce an invalid name; if it somehow does, that is a programmer error,+so we fail loudly with a clear message.+-}+forceQueueName :: Text -> QueueName+forceQueueName t =+    case Pgmq.parseQueueName t of+        Right qn -> qn+        Left err ->+            error+                ( "Keiro.PGMQ.Runtime.queueRef: derived an invalid PGMQ queue name "+                    <> show t+                    <> ": "+                    <> show err+                )++{- | Opaque runtime: a Hasql connection pool plus an optional OpenTelemetry+tracer. Construct it with 'withJobRuntime' (which manages the pool lifecycle).+-}+data JobRuntime = JobRuntime+    { runtimePool :: !Pool+    , runtimeTracer :: !(Maybe Tracer)+    }++{- | Acquire a Hasql pool from a libpq connection string, run @action@ with the+resulting 'JobRuntime', and release the pool afterwards (even on exception).+-}+withJobRuntime :: Text -> Maybe Tracer -> (JobRuntime -> IO a) -> IO a+withJobRuntime connStr tracer action =+    bracket acquire Pool.release $ \pool ->+        action JobRuntime{runtimePool = pool, runtimeTracer = tracer}+  where+    acquire =+        Pool.acquire $+            Pool.Config.settings+                [ Pool.Config.staticConnectionSettings (Conn.connectionString connStr)+                ]++{- | Run a @Reader PgmqAdapterEnv : Pgmq : Tracing : Error PgmqRuntimeError : IOE@+effect action against the runtime, surfacing PGMQ errors as a @Left@. The+'Reader' supplies the 'PgmqAdapterEnv' (built from the pool) that the+shibuya-pgmq adapter needs for its transactional ack/dead-letter path, so job+processors can construct their adapter without the pool being threaded by hand.+The tracer (if any) is threaded into both the shibuya 'Tracing' effect and the+@pgmq@ interpreter:++  * @Nothing@ → 'runTracingNoop' + 'runPgmq'+  * @Just tr@ → 'runTracing' + 'runPgmqTraced'++The interpreter order matters: @runPgmq@/@runPgmqTraced@ require+@Error PgmqRuntimeError :> es@ and @IOE :> es@, so we peel @Reader@ first (it is+pure), then @Pgmq@, then @Tracing@, then @Error@, then @IOE@.+-}+runJobEff ::+    JobRuntime ->+    Eff '[Reader PgmqAdapterEnv, Pgmq, Tracing, Error PgmqRuntimeError, IOE] a ->+    IO (Either PgmqRuntimeError a)+runJobEff rt act =+    runEff $+        runErrorNoCallStack @PgmqRuntimeError $+            case rt.runtimeTracer of+                Nothing -> runTracingNoop (runPgmq rt.runtimePool (runReader env act))+                Just tr -> runTracing tr (runPgmqTraced rt.runtimePool tr (runReader env act))+  where+    env = mkPgmqAdapterEnv rt.runtimePool
+ test/Main.hs view
@@ -0,0 +1,981 @@+{-# LANGUAGE BlockArguments #-}+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE DeriveAnyClass #-}+{-# LANGUAGE DerivingStrategies #-}+{-# LANGUAGE LambdaCase #-}+{-# LANGUAGE OverloadedRecordDot #-}+{-# LANGUAGE OverloadedStrings #-}++{- | End-to-end integration test for @keiro-pgmq@.++Uses @keiro-test-support@ to start one suite-level PostgreSQL server, installs+the PGMQ schema into the migrated template database, then gives every example a+fresh cloned database. The tests drive the package's public API against that+isolated database: 'enqueue' puts work on a queue, 'runJobOnce' drains it, and+we read the queue back through @pgmq-effectful@'s 'queueMetrics' to prove that a+@Done@ handler deletes the message, a @Retry@ handler leaves it, and a @Dead@+handler (or an undecodable payload) routes it to the dead-letter queue.+-}+module Main (main) where++import Control.Concurrent (threadDelay)+import Control.Exception (bracket, throwIO)+import Data.Aeson (FromJSON, ToJSON, Value (..), object, parseJSON, toJSON, (.=))+import Data.Aeson.Key qualified as Key+import Data.Aeson.KeyMap qualified as KeyMap+import Data.Aeson.Types (parseEither)+import Data.Either (isRight)+import Data.Foldable (toList, traverse_)+import Data.IORef (modifyIORef', newIORef, readIORef, writeIORef)+import Data.Int (Int32, Int64)+import Data.List (find)+import Data.List.NonEmpty (NonEmpty (..))+import Data.Text (Text)+import Data.Text qualified as Text+import Effectful (Eff, IOE, liftIO, (:>))+import Effectful.Error.Static (Error)+import Effectful.Reader.Static (Reader)+import GHC.Generics (Generic)+import Hasql.Connection.Settings qualified as Conn+import Hasql.Decoders qualified as Decoders+import Hasql.Encoders qualified as Encoders+import Hasql.Pool (Pool)+import Hasql.Pool qualified as Pool+import Hasql.Pool.Config qualified as Pool.Config+import Hasql.Session qualified as Session+import Hasql.Statement qualified as Statement+import Keiro.Codec (EventType (..))+import Keiro.Codec qualified as CoreCodec+import Keiro.PGMQ+import Keiro.Test.Postgres qualified as Postgres+import OpenTelemetry.Context qualified as Ctxt+import OpenTelemetry.Context.ThreadLocal qualified as CtxtLocal+import OpenTelemetry.Propagator.W3CTraceContext qualified as W3C+import OpenTelemetry.Trace.Core qualified as OTel+import OpenTelemetry.Trace.Id.Generator.Default (defaultIdGenerator)+import Pgmq.Config.Types qualified as Config+import Pgmq.Effectful (Message (..), MessageBody (..), Pgmq, QueueMetrics (..), ReadMessage (..), SendMessage (..))+import Pgmq.Effectful qualified as Pgmq+import Pgmq.Migration qualified as Migration+import Pgmq.Types (QueueName, parseQueueName, queueNameToText)+import Shibuya.Adapter.Pgmq (PgmqAdapterEnv)+import Shibuya.App (AppHandle, ShutdownConfig (..), SupervisionStrategy (IgnoreFailures), stopAppGracefully)+import Shibuya.Telemetry.Effect (Tracing)+import System.Timeout (timeout)+import Test.Hspec++-- | A sample job payload defined entirely in the test.+data Ping = Ping+    { message :: Text+    , count :: Int+    }+    deriving stock (Eq, Show, Generic)+    deriving anyclass (ToJSON, FromJSON)++-- | The effect stack 'runJobEff' interprets.+type Stack = '[Reader PgmqAdapterEnv, Pgmq, Tracing, Error PgmqRuntimeError, IOE]++main :: IO ()+main =+    Postgres.withMigratedSuiteWith installPgmq \fixture ->+        hspec $+            describe "Keiro.PGMQ" $+                around (Postgres.withFreshDatabase fixture) spec++-- | Install the PGMQ schema into the ephemeral database via @pgmq-migration@.+installPgmq :: Text -> IO ()+installPgmq connStr =+    withPool connStr $ \pool -> do+        result <- Pool.use pool Migration.migrate+        case result of+            Left usageErr -> fail ("pgmq pool error during migration: " <> show usageErr)+            Right (Left migErr) -> fail ("pgmq migration error: " <> show migErr)+            Right (Right ()) -> pure ()++withPool :: Text -> (Pool -> IO a) -> IO a+withPool connStr =+    bracket+        ( Pool.acquire $+            Pool.Config.settings+                [Pool.Config.staticConnectionSettings (Conn.connectionString connStr)]+        )+        Pool.release++{- | Count the rows currently in a DLQ's archive table @pgmq.a_<dlqPhysical>@ via a+raw @hasql@ session. PGMQ exposes no "read the archive" function, so retention is+proven with plain SQL. The queue name is sanitized to @[a-z0-9_]@ by 'queueRef',+so interpolating it into the table identifier is safe here.+-}+archiveCount :: Text -> Text -> IO Int64+archiveCount connStr dlqPhysical =+    withPool connStr $ \pool -> do+        let sql = "SELECT count(*) FROM pgmq.a_" <> dlqPhysical+            session =+                Session.statement () $+                    Statement.preparable+                        sql+                        Encoders.noParams+                        (Decoders.singleRow (Decoders.column (Decoders.nonNullable Decoders.int8)))+        result <- Pool.use pool session+        either (\e -> fail ("archive count failed: " <> show e)) pure result++{- | Run a @Stack@ action against a fresh 'JobRuntime' (no tracer), failing the+test on any PGMQ runtime error.+-}+runDb :: Text -> Eff Stack a -> IO a+runDb connStr act =+    withJobRuntime connStr Nothing $ \rt -> do+        res <- runJobEff rt act+        either (\e -> fail ("PGMQ runtime error: " <> show e)) pure res++-- | A job over 'Ping' with a distinct queue name per test (avoids collisions).+mkJob :: Text -> Job Ping+mkJob name =+    Job+        { jobName = name+        , jobQueue = queueRef name+        , jobCodec = aesonJobCodec+        , jobPolicy = defaultRetryPolicy+        }++versionedPingCodec :: CoreCodec.Codec Ping+versionedPingCodec =+    CoreCodec.Codec+        { eventTypes = EventType "ping" :| []+        , eventType = \_ -> EventType "ping"+        , schemaVersion = 2+        , encode = toJSON+        , decode = \_ value ->+            case parseEither parseJSON value of+                Left err -> Left (Text.pack err)+                Right ping -> Right ping+        , upcasters =+            [+                ( 1+                , \_ value ->+                    case value of+                        String msg ->+                            Right $+                                object+                                    [ "message" .= msg+                                    , "count" .= (1 :: Int)+                                    ]+                        _ -> Left "expected v1 string payload"+                )+            ]+        }++-- | Total number of messages currently on a queue (visible or not).+queueLen :: QueueName -> Eff Stack Int64+queueLen q = do+    metrics <- Pgmq.queueMetrics q+    pure metrics.queueLength++{- | Look up a queue by physical name in a 'Pgmq.listQueues' result and report+whether it is unlogged. 'Nothing' means the queue was not found.+-}+queueIsUnlogged :: QueueName -> [Pgmq.Queue] -> Maybe Bool+queueIsUnlogged qn queues =+    fmap (.isUnlogged) (find (\q -> q.name == qn) queues)++readOneIsEmpty :: QueueName -> Eff Stack Bool+readOneIsEmpty q = do+    messages <-+        Pgmq.readMessage+            ReadMessage+                { queueName = q+                , delay = 30+                , batchSize = Just 1+                , conditional = Nothing+                }+    pure (null messages)++-- | Read up to @n@ messages back off a queue (making them invisible for 30 s).+readMessages :: QueueName -> Int32 -> Eff Stack [Message]+readMessages q n = do+    messages <-+        Pgmq.readMessage+            ReadMessage+                { queueName = q+                , delay = 30+                , batchSize = Just n+                , conditional = Nothing+                }+    pure (toList messages)++-- | Look up a single key in a @Maybe Value@ header object.+headerKey :: Text -> Maybe Value -> Maybe Value+headerKey k = \case+    Just (Object o) -> KeyMap.lookup (Key.fromText k) o+    _ -> Nothing++{- | A real tracer provider with the W3C Trace Context propagator and a+non-dummy id generator, so an active span produces a @traceparent@ on injection.+No span processors are needed — the test inspects propagated headers, not+exported spans.+-}+setupW3CProvider :: IO OTel.TracerProvider+setupW3CProvider =+    OTel.createTracerProvider+        []+        OTel.emptyTracerProviderOptions+            { OTel.tracerProviderOptionsIdGenerator = defaultIdGenerator+            , OTel.tracerProviderOptionsPropagators = W3C.w3cTraceContextPropagator+            }++stopAppQuickly :: (IOE :> es) => AppHandle es -> Eff es ()+stopAppQuickly app = do+    _ <- stopAppGracefully ShutdownConfig{drainTimeout = 1} app+    pure ()++waitUntil :: IO Bool -> IO Bool+waitUntil predicate =+    maybe False id <$> timeout 10_000_000 loop+  where+    loop = do+        ok <- predicate+        if ok+            then pure True+            else threadDelay 100_000 >> loop++spec :: SpecWith Text+spec = do+    it "round-trips a payload through aesonJobCodec" $ \_connStr -> do+        let codec = aesonJobCodec :: JobCodec Ping+            sample = Ping "hello" 7+        decodeJob codec (encodeJob codec sample) `shouldBe` Right sample++    it "round-trips a payload through keiroJobCodec's versioned envelope" $ \_connStr -> do+        let codec = keiroJobCodec versionedPingCodec+            sample = Ping "hello" 7+        decodeJob codec (encodeJob codec sample) `shouldBe` Right sample++    it "decodes old keiroJobCodec payloads through the upcaster chain" $ \_connStr -> do+        let codec = keiroJobCodec versionedPingCodec+            v1Envelope =+                object+                    [ "v" .= (1 :: Int)+                    , "data" .= String "legacy"+                    ]+        decodeJob codec v1Envelope `shouldBe` Right (Ping "legacy" 1)++    it "classifies future keiroJobCodec payloads as retryable" $ \_connStr -> do+        let codec = keiroJobCodec versionedPingCodec+            futureEnvelope =+                object+                    [ "v" .= (99 :: Int)+                    , "data" .= object []+                    ]+        decodeJob codec futureEnvelope `shouldBe` Left (JobPayloadFromFuture 99 2)++    it "classifies malformed keiroJobCodec envelopes as malformed payloads" $ \_connStr -> do+        let codec = keiroJobCodec versionedPingCodec+        decodeJob codec (String "not an envelope") `shouldSatisfy` \case+            Left (JobPayloadMalformed _) -> True+            _ -> False++    it "validates retry policies" $ \_connStr -> do+        mkRetryPolicy 0 (RetryDelay 60) True+            `shouldBe` Left (NonPositiveMaxRetries 0)+        mkRetryPolicy 1 (RetryDelay (-1)) True+            `shouldBe` Left (NegativeRetryDelay (RetryDelay (-1)))+        mkRetryPolicy 1 (RetryDelay 0) True+            `shouldBe` Right (RetryPolicy 1 (RetryDelay 0) True)++    it "validates job tuning" $ \_connStr -> do+        mkJobTuning 0 1 (PollEvery 1)+            `shouldBe` Left (NonPositiveVisibilityTimeout 0)+        mkJobTuning 30 0 (PollEvery 1)+            `shouldBe` Left (NonPositiveBatchSize 0)+        mkJobTuning 30 1 (PollEvery 0)+            `shouldBe` Left NonPositivePollInterval+        mkJobTuning 30 1 (LongPoll 0 100)+            `shouldBe` Left NonPositivePollInterval+        mkJobTuning 30 1 (PollEvery 1)+            `shouldBe` Right defaultJobTuning++    it "derives distinct physical names for long logical queue names" $ \_connStr -> do+        let commonPrefix = Text.replicate 43 "a"+            first = queueRef (commonPrefix <> "x")+            second = queueRef (commonPrefix <> "y")+        first.physicalName `shouldNotBe` second.physicalName+        Text.length (queueNameToText first.physicalName) `shouldBe` 43+        Text.length (queueNameToText second.physicalName) `shouldBe` 43++    it "disambiguates logical names ending in _dlq from derived DLQ names" $ \_connStr -> do+        let foo = queueRef "foo"+            masquerading = queueRef "foo_dlq"+            masqueradingPhysical = queueNameToText masquerading.physicalName+        masquerading.physicalName `shouldNotBe` foo.dlqName+        masqueradingPhysical `shouldNotSatisfy` Text.isSuffixOf "_dlq"++    it "keeps short physical queue names unchanged" $ \_connStr -> do+        queueNameToText (queueRef "hospital_capacity.reservation_work").physicalName+            `shouldBe` "hospital_capacity_reservation_work"++    it "always derives PGMQ-parseable queue names" $ \_connStr -> do+        let logicalNames =+                [ ""+                , "!!!"+                , Text.replicate 100 "x"+                , "___trailing___"+                , "foo_dlq"+                , "9starts.with.digit"+                ]+        traverse_+            ( \logical -> do+                let ref = queueRef logical+                    physical = queueNameToText ref.physicalName+                    dlq = queueNameToText ref.dlqName+                parseQueueName physical `shouldSatisfy` isRight+                parseQueueName dlq `shouldSatisfy` isRight+                physical `shouldNotSatisfy` Text.isSuffixOf "_dlq"+            )+            logicalNames++    it "Done deletes the message" $ \connStr -> do+        let job = mkJob "keiro_pgmq_test.done"+        runDb connStr $ do+            ensureJobQueue job+            _ <- enqueue job (Ping "do" 1)+            runJobOnce 1 job (\_ -> pure Done)+        len <- runDb connStr (queueLen job.jobQueue.physicalName)+        len `shouldBe` 0++    it "Retry redelivers the message" $ \connStr -> do+        let job = mkJob "keiro_pgmq_test.retry"+        runDb connStr $ do+            ensureJobQueue job+            _ <- enqueue job (Ping "again" 2)+            runJobOnce 1 job (\_ -> pure (Retry (RetryDelay 0)))+        len <- runDb connStr (queueLen job.jobQueue.physicalName)+        len `shouldBe` 1++    it "RetryDefault redelivers after the policy default delay" $ \connStr -> do+        let job =+                (mkJob "keiro_pgmq_test.retry_default")+                    { jobPolicy = RetryPolicy 5 (RetryDelay 5) True+                    }+        runDb connStr $ do+            ensureJobQueue job+            _ <- enqueue job (Ping "again by default" 2)+            runJobOnce 1 job (\_ -> pure RetryDefault)+        len <- runDb connStr (queueLen job.jobQueue.physicalName)+        emptyImmediateRead <- runDb connStr (readOneIsEmpty job.jobQueue.physicalName)+        len `shouldBe` 1+        emptyImmediateRead `shouldBe` True++    it "runJobOnceWithContext returns promptly when n exceeds the queue length" $ \connStr -> do+        let job = mkJob "keiro_pgmq_test.once_short_queue"+        result <-+            timeout 2_000_000 $+                runDb connStr $ do+                    ensureJobQueue job+                    _ <- enqueue job (Ping "only" 1)+                    runJobOnceWithContext defaultJobTuning 5 job \_ctx _payload ->+                        pure Done+        result `shouldBe` Just 1+        len <- runDb connStr (queueLen job.jobQueue.physicalName)+        len `shouldBe` 0++    it "runJobOnceWithContext drains messages in batches greater than one" $ \connStr -> do+        handled <- newIORef (0 :: Int)+        let job = mkJob "keiro_pgmq_test.once_batch"+            tuning =+                either (error . show) id $+                    mkJobTuning 30 2 (PollEvery 1)+        drained <-+            runDb connStr $ do+                ensureJobQueue job+                _ <- enqueue job (Ping "first" 1)+                _ <- enqueue job (Ping "second" 2)+                _ <- enqueue job (Ping "third" 3)+                runJobOnceWithContext tuning 3 job \_ctx _payload -> do+                    liftIO $ modifyIORef' handled (+ 1)+                    pure Done+        drained `shouldBe` 3+        readIORef handled `shouldReturn` 3+        len <- runDb connStr (queueLen job.jobQueue.physicalName)+        len `shouldBe` 0++    it "runJobOnceWithContext Retry delay hides the message until the delay expires" $ \connStr -> do+        let job = mkJob "keiro_pgmq_test.once_retry_delay"+        drained <-+            runDb connStr $ do+                ensureJobQueue job+                _ <- enqueue job (Ping "later" 1)+                runJobOnceWithContext defaultJobTuning 1 job \_ctx _payload ->+                    pure (Retry (RetryDelay 5))+        drained `shouldBe` 1+        len <- runDb connStr (queueLen job.jobQueue.physicalName)+        emptyImmediateRead <- runDb connStr (readOneIsEmpty job.jobQueue.physicalName)+        len `shouldBe` 1+        emptyImmediateRead `shouldBe` True++    it "runJobOnceWithContext leaves thrown-handler messages invisible and continues" $ \connStr -> do+        let job = mkJob "keiro_pgmq_test.once_throw"+            tuning =+                either (error . show) id $+                    mkJobTuning 2 2 (PollEvery 1)+        drained <-+            runDb connStr $ do+                ensureJobQueue job+                _ <- enqueue job (Ping "throw" 1)+                _ <- enqueue job (Ping "ok" 2)+                runJobOnceWithContext tuning 2 job \_ctx payload ->+                    if payload.message == "throw"+                        then liftIO $ throwIO (userError "handler failed")+                        else pure Done+        drained `shouldBe` 1+        len <- runDb connStr (queueLen job.jobQueue.physicalName)+        emptyImmediateRead <- runDb connStr (readOneIsEmpty job.jobQueue.physicalName)+        len `shouldBe` 1+        emptyImmediateRead `shouldBe` True+        threadDelay 2_200_000+        drainedAfterVisibilityTimeout <-+            runDb connStr $+                runJobOnceWithContext tuning 1 job \_ctx _payload ->+                    pure Done+        drainedAfterVisibilityTimeout `shouldBe` 1++    it "runJobOnceWithContext auto-routes max-retry messages to the DLQ before rerunning the handler" $ \connStr -> do+        callCount <- newIORef (0 :: Int)+        let job =+                (mkJob "keiro_pgmq_test.once_max_retries")+                    { jobPolicy = RetryPolicy 1 (RetryDelay 0) True+                    }+        firstDrain <-+            runDb connStr $ do+                ensureJobQueue job+                _ <- enqueue job (Ping "retry-limit" 1)+                runJobOnceWithContext defaultJobTuning 1 job \_ctx _payload -> do+                    liftIO $ modifyIORef' callCount (+ 1)+                    pure (Retry (RetryDelay 0))+        secondDrain <-+            runDb connStr $+                runJobOnceWithContext defaultJobTuning 1 job \_ctx _payload -> do+                    liftIO $ modifyIORef' callCount (+ 1)+                    pure Done+        firstDrain `shouldBe` 1+        secondDrain `shouldBe` 1+        readIORef callCount `shouldReturn` 1+        mainLen <- runDb connStr (queueLen job.jobQueue.physicalName)+        dlqLen <- runDb connStr (queueLen job.jobQueue.dlqName)+        mainLen `shouldBe` 0+        dlqLen `shouldBe` 1++    it "worker-path lease extension prevents redelivery" $ \connStr -> do+        callCount <- newIORef (0 :: Int)+        handlerDone <- newIORef False+        let job = mkJob "keiro_pgmq_test.worker_lease"+            tuning =+                either (error . show) id $+                    mkJobTuning 2 1 (PollEvery 0.2)+        processed <-+            runDb connStr $ do+                ensureJobQueue job+                _ <- enqueue job (Ping "slow" 4)+                result <-+                    runJobWorkers+                        IgnoreFailures+                        16+                        [ jobProcessorWithContext tuning job \ctx _payload -> do+                            liftIO $ modifyIORef' callCount (+ 1)+                            ctx.extendLease 30+                            liftIO $ threadDelay 4_000_000+                            liftIO $ writeIORef handlerDone True+                            pure Done+                        ]+                case result of+                    Left err -> liftIO $ fail ("runJobWorkers failed: " <> show err)+                    Right app -> do+                        ok <- liftIO $ waitUntil (readIORef handlerDone)+                        stopAppQuickly app+                        pure ok+        processed `shouldBe` True+        readIORef callCount `shouldReturn` 1+        len <- runDb connStr (queueLen job.jobQueue.physicalName)+        len `shouldBe` 0++    it "worker-path context exposes the first attempt number" $ \connStr -> do+        seenAttempt <- newIORef Nothing+        let job = mkJob "keiro_pgmq_test.worker_attempt"+        processed <-+            runDb connStr $ do+                ensureJobQueue job+                _ <- enqueue job (Ping "attempt" 1)+                result <-+                    runJobWorkers+                        IgnoreFailures+                        16+                        [ jobProcessorWithContext defaultJobTuning job \ctx _payload -> do+                            liftIO $ writeIORef seenAttempt (Just ctx.attempt)+                            pure Done+                        ]+                case result of+                    Left err -> liftIO $ fail ("runJobWorkers failed: " <> show err)+                    Right app -> do+                        ok <- liftIO $ waitUntil ((/= Nothing) <$> readIORef seenAttempt)+                        stopAppQuickly app+                        pure ok+        processed `shouldBe` True+        readIORef seenAttempt `shouldReturn` Just (Just 0)++    it "runJobWorkers processes an enqueued message" $ \connStr -> do+        processedRef <- newIORef False+        let job = mkJob "keiro_pgmq_test.worker_smoke"+        processed <-+            runDb connStr $ do+                ensureJobQueue job+                _ <- enqueue job (Ping "worker" 1)+                result <-+                    runJobWorkers+                        IgnoreFailures+                        16+                        [ jobProcessor job \_payload -> do+                            liftIO $ writeIORef processedRef True+                            pure Done+                        ]+                case result of+                    Left err -> liftIO $ fail ("runJobWorkers failed: " <> show err)+                    Right app -> do+                        ok <- liftIO $ waitUntil (readIORef processedRef)+                        stopAppQuickly app+                        pure ok+        processed `shouldBe` True+        len <- runDb connStr (queueLen job.jobQueue.physicalName)+        len `shouldBe` 0++    it "runJobWorkers survives a transient database error during polling" $ \_connStr ->+        pendingWith "needs a deterministic keiro-pgmq-level transient polling fault injector; EP-1 covers this in upstream shibuya and shibuya-pgmq-adapter tests"++    it "worker-path retry limit auto-routes to the DLQ before the handler reruns" $ \connStr -> do+        callCount <- newIORef (0 :: Int)+        let job =+                (mkJob "keiro_pgmq_test.worker_max_retries")+                    { jobPolicy = RetryPolicy 1 (RetryDelay 0) True+                    }+            tuning =+                either (error . show) id $+                    mkJobTuning 30 1 (PollEvery 0.1)+        dlqReached <-+            runDb connStr $ do+                ensureJobQueue job+                _ <- enqueue job (Ping "retry-limit" 1)+                result <-+                    runJobWorkers+                        IgnoreFailures+                        16+                        [ jobProcessorWithContext tuning job \_ctx _payload -> do+                            liftIO $ modifyIORef' callCount (+ 1)+                            pure (Retry (RetryDelay 0))+                        ]+                case result of+                    Left err -> liftIO $ fail ("runJobWorkers failed: " <> show err)+                    Right app -> do+                        ok <- liftIO $ waitUntil do+                            dlqLen <- runDb connStr (queueLen job.jobQueue.dlqName)+                            pure (dlqLen == 1)+                        stopAppQuickly app+                        pure ok+        dlqReached `shouldBe` True+        readIORef callCount `shouldReturn` 1++    it "enqueueWithDelay delays first delivery" $ \connStr -> do+        let job = mkJob "keiro_pgmq_test.enqueue_delay"+        runDb connStr $ do+            ensureJobQueue job+            _ <- enqueueWithDelay job 5 (Ping "later" 1)+            pure ()+        len <- runDb connStr (queueLen job.jobQueue.physicalName)+        emptyImmediateRead <- runDb connStr (readOneIsEmpty job.jobQueue.physicalName)+        len `shouldBe` 1+        emptyImmediateRead `shouldBe` True++    it "Dead routes the message to the DLQ" $ \connStr -> do+        let job = mkJob "keiro_pgmq_test.dead"+        runDb connStr $ do+            ensureJobQueue job+            _ <- enqueue job (Ping "poison" 3)+            runJobOnce 1 job (\_ -> pure (Dead "bad"))+        mainLen <- runDb connStr (queueLen job.jobQueue.physicalName)+        dlqLen <- runDb connStr (queueLen job.jobQueue.dlqName)+        mainLen `shouldBe` 0+        dlqLen `shouldBe` 1++    it "readDlq decodes the original dead-lettered payload" $ \connStr -> do+        let job = mkJob "keiro_pgmq_test.dlq_read"+            payload = Ping "poison" 3+        entries <-+            runDb connStr $ do+                ensureJobQueue job+                _ <- enqueue job payload+                runJobOnce 1 job (\_ -> pure (Dead "bad"))+                readDlq job 1+        case entries of+            [entry] -> do+                entry.reason `shouldSatisfy` Text.isPrefixOf "poison_pill"+                entry.originalPayload `shouldBe` Right payload+                entry.originalMessageId `shouldSatisfy` (/= Nothing)+                entry.readCount `shouldBe` Just 1+            _ -> expectationFailure ("expected one DLQ entry, got " <> show (length entries))++    it "redriveDlq moves dead-lettered payloads back to the main queue" $ \connStr -> do+        let job = mkJob "keiro_pgmq_test.dlq_redrive"+        redriven <-+            runDb connStr $ do+                ensureJobQueue job+                _ <- enqueue job (Ping "redrive" 1)+                runJobOnce 1 job (\_ -> pure (Dead "bad"))+                redriveDlq job 10+        redriven `shouldBe` 1+        dlqLen <- runDb connStr (queueLen job.jobQueue.dlqName)+        mainLen <- runDb connStr (queueLen job.jobQueue.physicalName)+        dlqLen `shouldBe` 0+        mainLen `shouldBe` 1+        runDb connStr $+            runJobOnce 1 job (\_ -> pure Done)+        finalMainLen <- runDb connStr (queueLen job.jobQueue.physicalName)+        finalMainLen `shouldBe` 0++    it "purgeDlq empties the DLQ" $ \connStr -> do+        let job = mkJob "keiro_pgmq_test.dlq_purge"+        runDb connStr $ do+            ensureJobQueue job+            _ <- enqueue job (Ping "purge" 1)+            runJobOnce 1 job (\_ -> pure (Dead "bad"))+            purgeDlq job+        dlqLen <- runDb connStr (queueLen job.jobQueue.dlqName)+        dlqLen `shouldBe` 0++    it "readDlq preserves malformed DLQ wrappers as malformed entries" $ \connStr -> do+        let job = mkJob "keiro_pgmq_test.dlq_malformed"+        entries <-+            runDb connStr $ do+                ensureJobQueue job+                _ <-+                    Pgmq.sendMessage+                        SendMessage+                            { queueName = job.jobQueue.dlqName+                            , messageBody = MessageBody (String "not a dlq wrapper")+                            , delay = Nothing+                            }+                readDlq job 1+        case entries of+            [entry] -> do+                entry.reason `shouldSatisfy` Text.isPrefixOf "malformed_dlq_payload"+                entry.originalPayload `shouldSatisfy` \case+                    Left (JobPayloadMalformed _) -> True+                    _ -> False+            _ -> expectationFailure ("expected one malformed DLQ entry, got " <> show (length entries))++    it "undecodable payload routes to the DLQ" $ \connStr -> do+        let job = mkJob "keiro_pgmq_test.bad"+        runDb connStr $ do+            ensureJobQueue job+            -- Send raw JSON the Ping codec cannot decode, bypassing enqueue.+            _ <-+                Pgmq.sendMessage+                    SendMessage+                        { queueName = job.jobQueue.physicalName+                        , messageBody = MessageBody (String "not a ping")+                        , delay = Nothing+                        }+            runJobOnce 1 job (\_ -> pure Done)+        mainLen <- runDb connStr (queueLen job.jobQueue.physicalName)+        dlqLen <- runDb connStr (queueLen job.jobQueue.dlqName)+        mainLen `shouldBe` 0+        dlqLen `shouldBe` 1++    -- EP-1 M1: header-carrying enqueue and the reserved-key contract.+    it "enqueueWithHeaders attaches a header readable on the raw PGMQ message" $ \connStr -> do+        let job = mkJob "keiro_pgmq_test.hdr_attach"+        msgs <-+            runDb connStr $ do+                ensureJobQueue job+                _ <-+                    enqueueWithHeaders+                        job+                        (MessageHeaders (object ["tenant" .= ("acme" :: Text)]))+                        (Ping "hdr" 1)+                readMessages job.jobQueue.physicalName 1+        case msgs of+            [m] -> headerKey "tenant" m.headers `shouldBe` Just (String "acme")+            _ -> expectationFailure ("expected one message, got " <> show (length msgs))++    it "enqueueWithHeaders leaves the x-pgmq-group key untouched" $ \connStr -> do+        let job = mkJob "keiro_pgmq_test.hdr_group"+        msgs <-+            runDb connStr $ do+                ensureJobQueue job+                _ <-+                    enqueueWithHeaders+                        job+                        (MessageHeaders (object ["x-pgmq-group" .= ("g1" :: Text)]))+                        (Ping "g" 1)+                readMessages job.jobQueue.physicalName 1+        case msgs of+            [m] -> headerKey "x-pgmq-group" m.headers `shouldBe` Just (String "g1")+            _ -> expectationFailure ("expected one message, got " <> show (length msgs))++    -- EP-1 M2: batch enqueue.+    it "enqueueBatch of three payloads yields three ids and queue depth three" $ \connStr -> do+        let job = mkJob "keiro_pgmq_test.batch"+        ids <-+            runDb connStr $ do+                ensureJobQueue job+                enqueueBatch job [Ping "a" 1, Ping "b" 2, Ping "c" 3]+        length ids `shouldBe` 3+        len <- runDb connStr (queueLen job.jobQueue.physicalName)+        len `shouldBe` 3++    it "enqueueBatchWithHeaders attaches per-message headers" $ \connStr -> do+        let job = mkJob "keiro_pgmq_test.batch_headers"+        msgs <-+            runDb connStr $ do+                ensureJobQueue job+                _ <-+                    enqueueBatchWithHeaders+                        job+                        [ (MessageHeaders (object ["i" .= (1 :: Int)]), Ping "a" 1)+                        , (MessageHeaders (object ["i" .= (2 :: Int)]), Ping "b" 2)+                        ]+                readMessages job.jobQueue.physicalName 2+        length msgs `shouldBe` 2+        map (headerKey "i" . (.headers)) msgs+            `shouldMatchList` [Just (Number 1), Just (Number 2)]++    -- EP-1 M3: handler-visible headers and trace propagation.+    it "drain-path JobContext exposes the enqueued headers" $ \connStr -> do+        seen <- newIORef Nothing+        let job = mkJob "keiro_pgmq_test.ctx_headers"+        runDb connStr $ do+            ensureJobQueue job+            _ <-+                enqueueWithHeaders+                    job+                    (MessageHeaders (object ["tenant" .= ("acme" :: Text)]))+                    (Ping "h" 1)+            _ <-+                runJobOnceWithContext defaultJobTuning 1 job \ctx _payload -> do+                    liftIO (writeIORef seen ctx.headers)+                    pure Done+            pure ()+        captured <- readIORef seen+        headerKey "tenant" captured `shouldBe` Just (String "acme")++    it "a traceparent set at enqueue is visible to the drain-path handler" $ \connStr -> do+        seen <- newIORef Nothing+        provider <- setupW3CProvider+        let tracer = OTel.makeTracer provider "keiro-pgmq-test" OTel.tracerOptions+            job = mkJob "keiro_pgmq_test.traceparent"+        parentSpan <- OTel.createSpan tracer Ctxt.empty "enqueue" OTel.defaultSpanArguments+        _ <- CtxtLocal.attachContext (Ctxt.insertSpan parentSpan Ctxt.empty)+        runDb connStr $ do+            ensureJobQueue job+            _ <- enqueueTraced provider job (MessageHeaders (object [])) (Ping "t" 1)+            _ <-+                runJobOnceWithContext defaultJobTuning 1 job \ctx _payload -> do+                    liftIO (writeIORef seen ctx.headers)+                    pure Done+            pure ()+        OTel.endSpan parentSpan Nothing+        captured <- readIORef seen+        headerKey "traceparent" captured `shouldSatisfy` \case+            Just (String _) -> True+            _ -> False++    -- EP-2 M1: unlogged vs standard provisioning.+    it "ensureJobQueueWith unlogged creates an unlogged queue" $ \connStr -> do+        let job = mkJob "keiro_pgmq_test.unlogged"+        unlogged <-+            runDb connStr $ do+                ensureJobQueueWith unloggedProvision job+                queues <- Pgmq.listQueues+                pure (queueIsUnlogged job.jobQueue.physicalName queues)+        unlogged `shouldBe` Just True++    it "ensureJobQueue (standard) creates a logged queue" $ \connStr -> do+        let job = mkJob "keiro_pgmq_test.standard_logged"+        unlogged <-+            runDb connStr $ do+                ensureJobQueue job+                queues <- Pgmq.listQueues+                pure (queueIsUnlogged job.jobQueue.physicalName queues)+        unlogged `shouldBe` Just False++    -- EP-2 M2: partitioned config shape (pure) + pending live test.+    it "ensureJobQueueWith partitioned builds a partitioned QueueConfig" $ \_connStr -> do+        let job = mkJob "keiro_pgmq_test.partitioned"+            spec = PartitionSpec{partitionInterval = "daily", retentionInterval = "7 days"}+        case queueProvisionConfigs (partitionedProvision spec) job of+            (mainCfg : _) ->+                case mainCfg.queueType of+                    Config.PartitionedQueue pc -> do+                        pc.partitionInterval `shouldBe` "daily"+                        pc.retentionInterval `shouldBe` "7 days"+                        mainCfg.queueName `shouldBe` job.jobQueue.physicalName+                    other ->+                        expectationFailure+                            ("expected PartitionedQueue, got " <> show other)+            [] -> expectationFailure "expected at least the main queue config"++    it "ensureJobQueueWith partitioned creates a partitioned queue (live)" $ \_connStr ->+        pendingWith+            "requires a pg_partman-enabled PostgreSQL; the keiro test database installs only \+            \the PGMQ schema via pgmq-migration, which does not load pg_partman"++    -- EP-2 M3: FIFO index idempotence.+    it "ensureFifoIndex is idempotent and the queue still accepts reads" $ \connStr -> do+        let job = mkJob "keiro_pgmq_test.fifo_index"+        roundTripped <-+            runDb connStr $ do+                ensureJobQueue job+                ensureFifoIndex job+                ensureFifoIndex job -- second call must not error+                _ <- enqueue job (Ping "after-index" 1)+                runJobOnce 1 job (\_ -> pure Done)+                queueLen job.jobQueue.physicalName+        roundTripped `shouldBe` 0++    -- EP-3 M3: group-keyed producer + ordered queue setup.+    it "enqueueToGroup writes the x-pgmq-group header" $ \connStr -> do+        let job = mkJob "keiro_pgmq_test.group_header"+        msgs <-+            runDb connStr $ do+                ensureOrderedJobQueue job+                _ <- enqueueToGroup job "g1" (Ping "grouped" 1)+                readMessages job.jobQueue.physicalName 1+        case msgs of+            [m] -> headerKey "x-pgmq-group" m.headers `shouldBe` Just (String "g1")+            _ -> expectationFailure ("expected one message, got " <> show (length msgs))++    it "ensureOrderedJobQueue is idempotent and the queue accepts grouped work" $ \connStr -> do+        let job = mkJob "keiro_pgmq_test.ordered_setup"+        len <-+            runDb connStr $ do+                ensureOrderedJobQueue job+                ensureOrderedJobQueue job -- second call must not error+                _ <- enqueueToGroup job "g1" (Ping "x" 1)+                _ <-+                    runJobOnceWithContext (withOrdering FifoThroughput defaultJobTuning) 1 job \_ctx _p ->+                        pure Done+                queueLen job.jobQueue.physicalName+        len `shouldBe` 0++    -- EP-3 M4: end-to-end ordering proof.+    it "FifoThroughput drain preserves strict within-group order and fully drains" $ \connStr -> do+        observed <- newIORef ([] :: [Text])+        let job = mkJob "keiro_pgmq_test.fifo_order"+        drained <-+            runDb connStr $ do+                ensureOrderedJobQueue job+                _ <- enqueueToGroup job "a" (Ping "a1" 1)+                _ <- enqueueToGroup job "b" (Ping "b1" 1)+                _ <- enqueueToGroup job "a" (Ping "a2" 2)+                _ <- enqueueToGroup job "a" (Ping "a3" 3)+                _ <- enqueueToGroup job "b" (Ping "b2" 2)+                runJobOnceWithContext (withOrdering FifoThroughput defaultJobTuning) 5 job \_ctx payload -> do+                    liftIO $ modifyIORef' observed (<> [payload.message])+                    pure Done+        log' <- readIORef observed+        drained `shouldBe` 5+        len <- runDb connStr (queueLen job.jobQueue.physicalName)+        len `shouldBe` 0+        filter (Text.isPrefixOf "a") log' `shouldBe` ["a1", "a2", "a3"]+        filter (Text.isPrefixOf "b") log' `shouldBe` ["b1", "b2"]++    it "FifoThroughput worker path preserves within-group order" $ \connStr -> do+        observed <- newIORef ([] :: [Text])+        let job = mkJob "keiro_pgmq_test.fifo_worker"+            tuning =+                withOrdering FifoThroughput $+                    either (error . show) id $+                        mkJobTuning 30 1 (PollEvery 0.1)+        processed <-+            runDb connStr $ do+                ensureOrderedJobQueue job+                _ <- enqueueToGroup job "a" (Ping "a1" 1)+                _ <- enqueueToGroup job "a" (Ping "a2" 2)+                _ <- enqueueToGroup job "a" (Ping "a3" 3)+                result <-+                    runJobWorkers+                        IgnoreFailures+                        16+                        [ jobProcessorWithContext tuning job \_ctx payload -> do+                            liftIO $ modifyIORef' observed (<> [payload.message])+                            pure Done+                        ]+                case result of+                    Left err -> liftIO $ fail ("runJobWorkers failed: " <> show err)+                    Right app -> do+                        ok <- liftIO $ waitUntil ((>= 3) . length <$> readIORef observed)+                        stopAppQuickly app+                        pure ok+        processed `shouldBe` True+        log' <- readIORef observed+        filter (Text.isPrefixOf "a") log' `shouldBe` ["a1", "a2", "a3"]+        len <- runDb connStr (queueLen job.jobQueue.physicalName)+        len `shouldBe` 0++    -- EP-4 M1: typed metrics surface (main + DLQ).+    it "jobQueueMetrics reports main-queue depth after enqueue" $ \connStr -> do+        let job = mkJob "keiro_pgmq_test.metrics_depth"+        (mainMetrics, dlqMetrics) <-+            runDb connStr $ do+                ensureJobQueue job+                _ <- enqueue job (Ping "a" 1)+                _ <- enqueue job (Ping "b" 2)+                _ <- enqueue job (Ping "c" 3)+                mainMetrics <- jobQueueMetrics job+                dlqMetrics <- jobDlqMetrics job+                pure (mainMetrics, dlqMetrics)+        mainMetrics.queueLength `shouldBe` 3+        mainMetrics.queueVisibleLength `shouldBe` 3+        dlqMetrics.queueLength `shouldBe` 0++    it "jobDlqMetrics reports DLQ depth after a Dead outcome" $ \connStr -> do+        let job = mkJob "keiro_pgmq_test.metrics_dlq"+        (mainMetrics, dlqMetrics) <-+            runDb connStr $ do+                ensureJobQueue job+                _ <- enqueue job (Ping "poison" 1)+                runJobOnce 1 job (\_ -> pure (Dead "bad"))+                mainMetrics <- jobQueueMetrics job+                dlqMetrics <- jobDlqMetrics job+                pure (mainMetrics, dlqMetrics)+        mainMetrics.queueLength `shouldBe` 0+        dlqMetrics.queueLength `shouldBe` 1++    -- EP-4 M2: archive/retention API.+    it "archiveDlq retains dead-lettered rows in the archive table" $ \connStr -> do+        let job = mkJob "keiro_pgmq_test.dlq_archive"+        (archived, dlqLen) <-+            runDb connStr $ do+                ensureJobQueue job+                _ <- enqueue job (Ping "poison" 1)+                runJobOnce 1 job (\_ -> pure (Dead "bad"))+                archived <- archiveDlq job 10+                dlqMetrics <- jobDlqMetrics job+                pure (archived, dlqMetrics.queueLength)+        archived `shouldBe` 1+        dlqLen `shouldBe` 0+        retained <- archiveCount connStr (queueNameToText job.jobQueue.dlqName)+        retained `shouldBe` 1++    -- EP-4 M3: end-to-end retention lifecycle.+    it "archived DLQ rows survive a purge" $ \connStr -> do+        let job = mkJob "keiro_pgmq_test.dlq_archive_purge"+        archived <-+            runDb connStr $ do+                ensureJobQueue job+                _ <- enqueue job (Ping "poison" 1)+                runJobOnce 1 job (\_ -> pure (Dead "bad"))+                archived <- archiveDlq job 10+                purgeDlq job+                pure archived+        archived `shouldBe` 1+        dlqLen <- runDb connStr (queueLen job.jobQueue.dlqName)+        dlqLen `shouldBe` 0+        retained <- archiveCount connStr (queueNameToText job.jobQueue.dlqName)+        retained `shouldBe` 1