packages feed

keiro-pgmq 0.5.0.0 → 0.6.0.0

raw patch · 9 files changed

+2695/−2751 lines, 9 filesdep ~keiro-corePVP ok

version bump matches the API change (PVP)

Dependency ranges changed: keiro-core

API changes (from Hackage documentation)

Files

CHANGELOG.md view
@@ -6,6 +6,11 @@  ## [Unreleased] +## 0.6.0.0 — 2026-07-31++No user-facing changes beyond the lockstep `keiro-core ^>=0.6.0.0` bound.+Released with the package set for the `keiro-dsl` 0.6.0.0 work.+ ## 0.5.0.0 — 2026-07-31  No changes this release. Released with the package set for the `keiro-dsl`
keiro-pgmq.cabal view
@@ -1,6 +1,6 @@ cabal-version:   3.0 name:            keiro-pgmq-version:         0.5.0.0+version:         0.6.0.0 synopsis:        PostgreSQL job-queue (PGMQ) integration for Keiro description:   A typed background-job queue for Keiro applications on top of PGMQ (the@@ -58,7 +58,7 @@     , hasql                 >=1.10     && <1.11     , hasql-pool            >=1.2      && <1.5     , hs-opentelemetry-api  >=1.0      && <1.1-    , keiro-core            ^>=0.5.0.0+    , keiro-core            ^>=0.6.0.0     , pgmq-config           >=0.4      && <0.5     , pgmq-core             >=0.4      && <0.5     , pgmq-effectful        >=0.4      && <0.5@@ -86,7 +86,7 @@     , hs-opentelemetry-propagator-w3c      >=1.0      && <1.1     , hs-opentelemetry-sdk                 >=1.0      && <1.1     , hspec                                >=2.11-    , keiro-core                           ^>=0.5.0.0+    , keiro-core                           ^>=0.6.0.0     , keiro-pgmq     , keiro-test-support     , pgmq-config                          >=0.4      && <0.5
src/Keiro/PGMQ.hs view
@@ -1,19 +1,19 @@-{- | 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,+-- | 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+  )+where  import Keiro.PGMQ.Codec import Keiro.PGMQ.Dlq
src/Keiro/PGMQ/Codec.hs view
@@ -1,37 +1,37 @@-{- | 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 (..),+-- | 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+  )+where  import "aeson" Data.Aeson (FromJSON, ToJSON, Value, object, parseJSON, toJSON, withObject, (.:), (.:?), (.=)) import "aeson" Data.Aeson.Types (parseEither)@@ -44,87 +44,84 @@  -- | 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)+  = -- | 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-    }+  { 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-        }+  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.--}+-- | 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'.--}+-- | 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)-        }+  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+  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)+      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)+  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
@@ -3,35 +3,35 @@ {-# 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 (..),+-- | 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+  )+where  import Keiro.PGMQ.Codec (JobDecodeError (..), decodeJob) import Keiro.PGMQ.Job (Job (..))@@ -42,15 +42,15 @@ import "base" Data.Foldable (toList) import "base" Data.Int (Int32, Int64) import "effectful-core" Effectful (Eff, IOE, (:>))-import "pgmq-effectful" Pgmq.Effectful (-    Message (..),+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@@ -58,187 +58,184 @@  -- | 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)+  { -- | The DLQ row's own PGMQ message id.+    dlqMessageId :: !MessageId,+    -- | @poison_pill: ...@, @invalid_payload: ...@, or @max_retries_exceeded@.+    reason :: !Text,+    -- | The preserved @original_message@ decoded with the job's codec.+    originalPayload :: !(Either JobDecodeError p),+    originalMessageId :: !(Maybe Int64),+    originalEnqueuedAt :: !(Maybe UTCTime),+    readCount :: !(Maybe Int64),+    -- | Full DLQ wrapper for forensics.+    rawBody :: !Value+  }+  deriving stock (Show)  data DlqEnvelope = DlqEnvelope-    { originalMessage :: !Value-    , deadLetterReason :: !Text-    , envelopeOriginalMessageId :: !(Maybe Int64)-    , envelopeOriginalEnqueuedAt :: !(Maybe UTCTime)-    , envelopeReadCount :: !(Maybe Int64)-    }+  { originalMessage :: !Value,+    deadLetterReason :: !Text,+    envelopeOriginalMessageId :: !(Maybe Int64),+    envelopeOriginalEnqueuedAt :: !(Maybe UTCTime),+    envelopeReadCount :: !(Maybe Int64)+  }  parseDlqEnvelope :: Value -> Either Text DlqEnvelope parseDlqEnvelope =-    firstText . parseEither parser+  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-                    }+      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+      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.--}+-- | 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))+  | 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-                    }+  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.--}+-- | 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+  | 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+      | 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 do-                    movedInBatch <- foldM redriveOne 0 messages-                    if movedInBatch == 0-                        then pure moved-                        else loop (moved + movedInBatch)+                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)+      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)+  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.--}+-- | 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+  | 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+      | 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 do-                    archivedInBatch <- foldM archiveOne 0 messages-                    if archivedInBatch == 0-                        then pure archived-                        else loop (archived + archivedInBatch)+                else loop (archived + archivedInBatch)      archiveOne count message = do-        moved <- archiveDlqEntry job message.messageId-        pure (if moved then count + 1 else count)+      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-            }+  Pgmq.archiveMessage+    MessageQuery+      { queueName = job.jobQueue.dlqName,+        messageId = msgId+      }
src/Keiro/PGMQ/Job.hs view
@@ -9,1079 +9,1045 @@ -- 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.--== Tracing--Both execution shapes propagate W3C trace context and emit the same common-per-message span. A message enqueued with 'enqueueTraced' carries @traceparent@-(and optional @tracestate@) in PGMQ's JSONB @headers@ column; at consumption-time that context is extracted and installed as the parent, so the handler's-span continues the producer's trace even across processes. The span is-Consumer-kind, named @\<jobName\> process@, and carries-@messaging.system=shibuya@, @messaging.destination.name=\<jobName\>@,-@messaging.operation.type=process@, @messaging.message.id@,-@shibuya.partition@ for FIFO deliveries, and @shibuya.ack.decision@ once the-message has actually been finalized. @AckOk@ and @AckRetry@ end the span @OK@;-dead-lettering and halting end it @ERROR@ with the reason.--The continuous 'runJobWorkers' path gets this from shibuya's supervised runner-and additionally reports @shibuya.inflight.count@ and @shibuya.inflight.max@.-The bounded 'runJobOnce' \/ 'runJobOnceWithContext' path opens the span itself-and deliberately omits those two: a direct drain has no shibuya inbox and no-concurrency meter to describe. Lower-level PGMQ operation spans-(@publish \<queue\>@, @receive \<queue\>@, deletes, visibility changes, DLQ-sends) come from the traced @pgmq-effectful@ interpreter and are unaffected.--Tracing is opt-in: with no tracer wired into the runtime (see-'Keiro.PGMQ.Runtime.withJobRuntime'), every span operation is a no-op and-processing behavior is identical.--}-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 (..),-    HaltReason (..),-    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 (..))---- Qualified only for 'unMessageId': shibuya's @MessageId@ type name would--- otherwise collide with @Pgmq.Effectful@'s, which the producer signatures use.-import "shibuya-core" Shibuya.Core.Types qualified as ShibuyaTypes-import "shibuya-core" Shibuya.Telemetry.Effect (-    Span,-    SpanStatus (..),-    Tracing,-    addAttribute,-    addEvent,-    recordException,-    setStatus,-    toAttribute,-    withExtractedContext,-    withSpan',- )-import "shibuya-core" Shibuya.Telemetry.Propagation (extractTraceContext)-import "shibuya-core" Shibuya.Telemetry.Semantic (-    attrMessagingDestinationName,-    attrMessagingMessageId,-    attrMessagingOperation,-    attrMessagingSystem,-    attrShibuyaAckDecision,-    attrShibuyaPartition,-    consumerSpanArgs,-    eventHandlerCompleted,-    eventHandlerStarted,-    mkEvent,-    processSpanName,- )-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 "text" Data.Text qualified as 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.--Shibuya's supervised runner opens the per-message @\<jobName\> process@ span-described in the module's tracing section, continuing the producer's trace from-the message's @traceparent@. Because this path owns an inbox and a concurrency-limit, its spans additionally carry @shibuya.inflight.count@ and-@shibuya.inflight.max@, which the bounded 'runJobOnceWithContext' path has no-equivalent for.--}-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--{- | Open the one-shot equivalent of shibuya's per-message processing span.--The continuous worker path gets this from shibuya's supervised runner; the-direct drain has no runner, so it opens the same span itself. The trace context-that 'enqueueTraced' wrote into the PGMQ @headers@ column (and that-'pgmqMessageToEnvelope' projects onto @Envelope.traceContext@) is installed as-the parent for the dynamic extent of this one delivery, so the span continues-the producer's trace across processes rather than starting a new one. Deliveries-without a usable @traceparent@ fall back to whatever local context is active,-and still get exactly one span.--The attribute set is deliberately the subset the two execution shapes agree on:-the OTel @messaging.*@ quartet plus @shibuya.partition@ for FIFO deliveries. The-@shibuya.inflight.*@ gauges are omitted because the direct drain has no shibuya-inbox and no concurrency meter to report.--}-withOneShotProcessSpan ::-    (IOE :> es, Tracing :> es) =>-    Job p ->-    Envelope Value ->-    (Span -> Eff es a) ->-    Eff es a-withOneShotProcessSpan job envelope act =-    withExtractedContext (envelope.traceContext >>= extractTraceContext) $-        withSpan' (processSpanName job.jobName) consumerSpanArgs $ \traceSpan -> do-            let ShibuyaTypes.MessageId messageIdText = envelope.messageId-            addAttribute traceSpan attrMessagingSystem ("shibuya" :: Text)-            addAttribute traceSpan attrMessagingDestinationName job.jobName-            addAttribute traceSpan attrMessagingOperation ("process" :: Text)-            addAttribute traceSpan attrMessagingMessageId messageIdText-            case envelope.partition of-                Just partition -> addAttribute traceSpan attrShibuyaPartition partition-                Nothing -> pure ()-            act traceSpan--{- | Record a finalization that already succeeded on the process span, using the-same decision text and status mapping as shibuya's continuous runner. Call this-only /after/ the corresponding PGMQ statement returned, so the attribute never-claims an acknowledgement that did not happen.--}-recordAckOnSpan ::-    (IOE :> es, Tracing :> es) => Span -> AckDecision -> Eff es ()-recordAckOnSpan traceSpan decision = do-    let decisionText = ackDecisionText decision-    addEvent traceSpan $-        mkEvent eventHandlerCompleted [(attrShibuyaAckDecision, toAttribute decisionText)]-    addAttribute traceSpan attrShibuyaAckDecision decisionText-    setStatus traceSpan $ case decision of-        AckOk -> Ok-        AckRetry _ -> Ok-        AckDeadLetter reason -> Error (deadLetterReasonText reason)-        AckHalt reason -> Error (haltReasonText reason)---- | The @shibuya.ack.decision@ value for a decision, matching shibuya's runner.-ackDecisionText :: AckDecision -> Text-ackDecisionText AckOk = "ack_ok"-ackDecisionText (AckRetry _) = "ack_retry"-ackDecisionText (AckDeadLetter _) = "ack_dead_letter"-ackDecisionText (AckHalt _) = "ack_halt"---- | The @ERROR@ status description for a dead-letter, matching shibuya's runner.-deadLetterReasonText :: DeadLetterReason -> Text-deadLetterReasonText (PoisonPill t) = "poison_pill: " <> t-deadLetterReasonText (InvalidPayload t) = "invalid_payload: " <> t-deadLetterReasonText MaxRetriesExceeded = "max_retries_exceeded"---- | The @ERROR@ status description for a halt, matching shibuya's runner.-haltReasonText :: HaltReason -> Text-haltReasonText (HaltOrderedStream t) = "halt_ordered_stream: " <> t-haltReasonText (HaltFatal t) = "halt_fatal: " <> t--{- | 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.--Each claimed message is processed inside one Consumer-kind-@\<jobName\> process@ span that continues the producer's trace when the message-carries a W3C @traceparent@ (see 'enqueueTraced'), exactly as the continuous-'runJobWorkers' path does. The span carries @messaging.system@,-@messaging.destination.name@, @messaging.operation.type@,-@messaging.message.id@, @shibuya.partition@ for FIFO deliveries, and — once the-finalizing PGMQ statement has returned — @shibuya.ack.decision@ with a matching-span status. A handler that throws is recorded as an exception with an @ERROR@-status and no acknowledgement attribute, because the direct drain deliberately-issues no finalizer call and leaves the row for visibility-timeout redelivery.-Unlike the continuous path this span has no @shibuya.inflight.*@ attributes:-there is no shibuya inbox or concurrency meter behind a bounded drain.--}-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))--    -- One conversion point per delivery: the envelope supplies the payload, the-    -- attempt number, the FIFO partition, the message id, and the trace context.-    step count message = do-        let envelope = pgmqMessageToEnvelope message-        disposed <--            withOneShotProcessSpan job envelope (processMessage message envelope)-        pure $-            if disposed-                then count + 1-                else count--    -- Settle exactly as before; the span only observes what already happened.-    -- 'recordAckOnSpan' runs after 'ackMessage' returns, so a failed-    -- finalization propagates without leaving a false acknowledgement behind.-    processMessage message envelope traceSpan-        | message.readCount > job.jobPolicy.maxRetries =-            settle message traceSpan (AckDeadLetter MaxRetriesExceeded)-        | otherwise =-            case decodeJob job.jobCodec envelope.payload of-                Left (JobPayloadFromFuture _payloadVersion _workerVersion) ->-                    settle message traceSpan (AckRetry job.jobPolicy.defaultRetryDelay)-                Left (JobPayloadMalformed err) ->-                    settle message traceSpan (AckDeadLetter (InvalidPayload err))-                Right p -> do-                    addEvent traceSpan (mkEvent eventHandlerStarted [])-                    outcome <--                        EffException.try @SomeException (handle (contextFor message envelope) p)-                    case outcome of-                        Left handlerException -> do-                            -- No finalizer call: the row stays invisible until its-                            -- visibility timeout expires, so there is no ack to claim.-                            recordException traceSpan handlerException-                            setStatus traceSpan (Error (handlerExceptionText handlerException))-                            pure False-                        Right jobOutcome ->-                            settle message traceSpan (outcomeToAck jobOutcome)--    settle message traceSpan decision = do-        ackMessage message decision-        recordAckOnSpan traceSpan decision-        pure True--    handlerExceptionText ex = "handler exception: " <> Text.pack (show (ex :: SomeException))--    contextFor message envelope =-        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.--Each delivery is traced exactly as 'runJobOnceWithContext' describes.--}-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)+-- | 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.+--+-- == Tracing+--+-- Both execution shapes propagate W3C trace context and emit the same common+-- per-message span. A message enqueued with 'enqueueTraced' carries @traceparent@+-- (and optional @tracestate@) in PGMQ's JSONB @headers@ column; at consumption+-- time that context is extracted and installed as the parent, so the handler's+-- span continues the producer's trace even across processes. The span is+-- Consumer-kind, named @\<jobName\> process@, and carries+-- @messaging.system=shibuya@, @messaging.destination.name=\<jobName\>@,+-- @messaging.operation.type=process@, @messaging.message.id@,+-- @shibuya.partition@ for FIFO deliveries, and @shibuya.ack.decision@ once the+-- message has actually been finalized. @AckOk@ and @AckRetry@ end the span @OK@;+-- dead-lettering and halting end it @ERROR@ with the reason.+--+-- The continuous 'runJobWorkers' path gets this from shibuya's supervised runner+-- and additionally reports @shibuya.inflight.count@ and @shibuya.inflight.max@.+-- The bounded 'runJobOnce' \/ 'runJobOnceWithContext' path opens the span itself+-- and deliberately omits those two: a direct drain has no shibuya inbox and no+-- concurrency meter to describe. Lower-level PGMQ operation spans+-- (@publish \<queue\>@, @receive \<queue\>@, deletes, visibility changes, DLQ+-- sends) come from the traced @pgmq-effectful@ interpreter and are unaffected.+--+-- Tracing is opt-in: with no tracer wired into the runtime (see+-- 'Keiro.PGMQ.Runtime.withJobRuntime'), every span operation is a no-op and+-- processing behavior is identical.+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 (..),+    HaltReason (..),+    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 (..))+-- Qualified only for 'unMessageId': shibuya's @MessageId@ type name would+-- otherwise collide with @Pgmq.Effectful@'s, which the producer signatures use.+import "shibuya-core" Shibuya.Core.Types qualified as ShibuyaTypes+import "shibuya-core" Shibuya.Telemetry.Effect+  ( Span,+    SpanStatus (..),+    Tracing,+    addAttribute,+    addEvent,+    recordException,+    setStatus,+    toAttribute,+    withExtractedContext,+    withSpan',+  )+import "shibuya-core" Shibuya.Telemetry.Propagation (extractTraceContext)+import "shibuya-core" Shibuya.Telemetry.Semantic+  ( attrMessagingDestinationName,+    attrMessagingMessageId,+    attrMessagingOperation,+    attrMessagingSystem,+    attrShibuyaAckDecision,+    attrShibuyaPartition,+    consumerSpanArgs,+    eventHandlerCompleted,+    eventHandlerStarted,+    mkEvent,+    processSpanName,+  )+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 "text" Data.Text qualified as 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+  { -- | Used as the shibuya 'ProcessorId' and telemetry label.+    jobName :: !Text,+    jobQueue :: !QueueRef,+    jobCodec :: !(JobCodec p),+    jobPolicy :: !RetryPolicy+  }++-- | Per-delivery capabilities handed to context-aware handlers.+data JobContext es = JobContext+  { -- | Push the message's visibility timeout further into the future.+    extendLease :: !(NominalDiffTime -> Eff es ()),+    -- | Zero-based delivery attempt; @Just 0@ is the first delivery.+    attempt :: !(Maybe Word),+    -- | 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).+    headers :: !(Maybe Value)+  }++-- | 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.+--+-- Shibuya's supervised runner opens the per-message @\<jobName\> process@ span+-- described in the module's tracing section, continuing the producer's trace from+-- the message's @traceparent@. Because this path owns an inbox and a concurrency+-- limit, its spans additionally carry @shibuya.inflight.count@ and+-- @shibuya.inflight.max@, which the bounded 'runJobOnceWithContext' path has no+-- equivalent for.+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++-- | Open the one-shot equivalent of shibuya's per-message processing span.+--+-- The continuous worker path gets this from shibuya's supervised runner; the+-- direct drain has no runner, so it opens the same span itself. The trace context+-- that 'enqueueTraced' wrote into the PGMQ @headers@ column (and that+-- 'pgmqMessageToEnvelope' projects onto @Envelope.traceContext@) is installed as+-- the parent for the dynamic extent of this one delivery, so the span continues+-- the producer's trace across processes rather than starting a new one. Deliveries+-- without a usable @traceparent@ fall back to whatever local context is active,+-- and still get exactly one span.+--+-- The attribute set is deliberately the subset the two execution shapes agree on:+-- the OTel @messaging.*@ quartet plus @shibuya.partition@ for FIFO deliveries. The+-- @shibuya.inflight.*@ gauges are omitted because the direct drain has no shibuya+-- inbox and no concurrency meter to report.+withOneShotProcessSpan ::+  (IOE :> es, Tracing :> es) =>+  Job p ->+  Envelope Value ->+  (Span -> Eff es a) ->+  Eff es a+withOneShotProcessSpan job envelope act =+  withExtractedContext (envelope.traceContext >>= extractTraceContext) $+    withSpan' (processSpanName job.jobName) consumerSpanArgs $ \traceSpan -> do+      let ShibuyaTypes.MessageId messageIdText = envelope.messageId+      addAttribute traceSpan attrMessagingSystem ("shibuya" :: Text)+      addAttribute traceSpan attrMessagingDestinationName job.jobName+      addAttribute traceSpan attrMessagingOperation ("process" :: Text)+      addAttribute traceSpan attrMessagingMessageId messageIdText+      case envelope.partition of+        Just partition -> addAttribute traceSpan attrShibuyaPartition partition+        Nothing -> pure ()+      act traceSpan++-- | Record a finalization that already succeeded on the process span, using the+-- same decision text and status mapping as shibuya's continuous runner. Call this+-- only /after/ the corresponding PGMQ statement returned, so the attribute never+-- claims an acknowledgement that did not happen.+recordAckOnSpan ::+  (IOE :> es, Tracing :> es) => Span -> AckDecision -> Eff es ()+recordAckOnSpan traceSpan decision = do+  let decisionText = ackDecisionText decision+  addEvent traceSpan $+    mkEvent eventHandlerCompleted [(attrShibuyaAckDecision, toAttribute decisionText)]+  addAttribute traceSpan attrShibuyaAckDecision decisionText+  setStatus traceSpan $ case decision of+    AckOk -> Ok+    AckRetry _ -> Ok+    AckDeadLetter reason -> Error (deadLetterReasonText reason)+    AckHalt reason -> Error (haltReasonText reason)++-- | The @shibuya.ack.decision@ value for a decision, matching shibuya's runner.+ackDecisionText :: AckDecision -> Text+ackDecisionText AckOk = "ack_ok"+ackDecisionText (AckRetry _) = "ack_retry"+ackDecisionText (AckDeadLetter _) = "ack_dead_letter"+ackDecisionText (AckHalt _) = "ack_halt"++-- | The @ERROR@ status description for a dead-letter, matching shibuya's runner.+deadLetterReasonText :: DeadLetterReason -> Text+deadLetterReasonText (PoisonPill t) = "poison_pill: " <> t+deadLetterReasonText (InvalidPayload t) = "invalid_payload: " <> t+deadLetterReasonText MaxRetriesExceeded = "max_retries_exceeded"++-- | The @ERROR@ status description for a halt, matching shibuya's runner.+haltReasonText :: HaltReason -> Text+haltReasonText (HaltOrderedStream t) = "halt_ordered_stream: " <> t+haltReasonText (HaltFatal t) = "halt_fatal: " <> t++-- | 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.+--+-- Each claimed message is processed inside one Consumer-kind+-- @\<jobName\> process@ span that continues the producer's trace when the message+-- carries a W3C @traceparent@ (see 'enqueueTraced'), exactly as the continuous+-- 'runJobWorkers' path does. The span carries @messaging.system@,+-- @messaging.destination.name@, @messaging.operation.type@,+-- @messaging.message.id@, @shibuya.partition@ for FIFO deliveries, and — once the+-- finalizing PGMQ statement has returned — @shibuya.ack.decision@ with a matching+-- span status. A handler that throws is recorded as an exception with an @ERROR@+-- status and no acknowledgement attribute, because the direct drain deliberately+-- issues no finalizer call and leaves the row for visibility-timeout redelivery.+-- Unlike the continuous path this span has no @shibuya.inflight.*@ attributes:+-- there is no shibuya inbox or concurrency meter behind a bounded drain.+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))++    -- One conversion point per delivery: the envelope supplies the payload, the+    -- attempt number, the FIFO partition, the message id, and the trace context.+    step count message = do+      let envelope = pgmqMessageToEnvelope message+      disposed <-+        withOneShotProcessSpan job envelope (processMessage message envelope)+      pure $+        if disposed+          then count + 1+          else count++    -- Settle exactly as before; the span only observes what already happened.+    -- 'recordAckOnSpan' runs after 'ackMessage' returns, so a failed+    -- finalization propagates without leaving a false acknowledgement behind.+    processMessage message envelope traceSpan+      | message.readCount > job.jobPolicy.maxRetries =+          settle message traceSpan (AckDeadLetter MaxRetriesExceeded)+      | otherwise =+          case decodeJob job.jobCodec envelope.payload of+            Left (JobPayloadFromFuture _payloadVersion _workerVersion) ->+              settle message traceSpan (AckRetry job.jobPolicy.defaultRetryDelay)+            Left (JobPayloadMalformed err) ->+              settle message traceSpan (AckDeadLetter (InvalidPayload err))+            Right p -> do+              addEvent traceSpan (mkEvent eventHandlerStarted [])+              outcome <-+                EffException.try @SomeException (handle (contextFor message envelope) p)+              case outcome of+                Left handlerException -> do+                  -- No finalizer call: the row stays invisible until its+                  -- visibility timeout expires, so there is no ack to claim.+                  recordException traceSpan handlerException+                  setStatus traceSpan (Error (handlerExceptionText handlerException))+                  pure False+                Right jobOutcome ->+                  settle message traceSpan (outcomeToAck jobOutcome)++    settle message traceSpan decision = do+      ackMessage message decision+      recordAckOnSpan traceSpan decision+      pure True++    handlerExceptionText ex = "handler exception: " <> Text.pack (show (ex :: SomeException))++    contextFor message envelope =+      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.+--+-- Each delivery is traced exactly as 'runJobOnceWithContext' describes.+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
@@ -1,22 +1,22 @@ {-# 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 (..),+-- | 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+  )+where  import Keiro.PGMQ.Job (Job (..)) import Keiro.PGMQ.Runtime (QueueRef (..))@@ -36,8 +36,8 @@ -- | 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+  metrics <- jobQueueMetrics job+  pure metrics.queueVisibleLength  -- | Every queue's metrics (passthrough over 'Pgmq.Effectful.allQueueMetrics'); not Job-keyed. allJobMetrics :: (Pgmq :> es) => Eff es [QueueMetrics]
src/Keiro/PGMQ/Runtime.hs view
@@ -1,21 +1,20 @@ {-# 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+-- | 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, @@ -26,7 +25,8 @@      -- * Re-exports     PgmqRuntimeError,-) where+  )+where  import "base" Control.Exception (bracket) import "base" Data.Bits (xor)@@ -48,56 +48,54 @@ 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.--}+-- | 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.+  { -- | Caller-facing name; may contain dots or otherwise-illegal characters.+    logicalName :: !Text,+    -- | Derived, PGMQ-valid name used for the main queue.+    physicalName :: !QueueName,+    -- | Derived @"<physical>_dlq"@ name used for the dead-letter queue.+    dlqName :: !QueueName+  }+  deriving stock (Eq, Show) -For example, @queueRef "hospital_capacity.reservation_work"@ yields-@physicalName == "hospital_capacity_reservation_work"@ and-@dlqName == "hospital_capacity_reservation_work_dlq"@.--}+-- | 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")-        }+  QueueRef+    { logicalName = logical,+      physicalName = forceQueueName base,+      dlqName = forceQueueName (base <> "_dlq")+    }   where     base = physicalBase logical @@ -110,53 +108,51 @@  physicalBase :: Text -> Text physicalBase logical =-    if Text.length base <= maxBaseLength && not ("_dlq" `Text.isSuffixOf` base)-        then base-        else hashedBase logical base+  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+  ensureLeadingLetter+    . collapseUnderscores+    . Text.map toLegal+    . Text.toLower   where     toLegal c-        | (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' = c-        | otherwise = '_'+      | (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.--}+-- | 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 "_"+  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.--}+-- | 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+  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+  prefix <> "_" <> fnv1a64Hex logical   where     trimmedPrefix = Text.dropWhileEnd (== '_') (Text.take hashedPrefixLength base)     prefix-        | Text.null trimmedPrefix = "q"-        | otherwise = trimmedPrefix+      | Text.null trimmedPrefix = "q"+      | otherwise = trimmedPrefix  fnv1a64Hex :: Text -> Text fnv1a64Hex logical =-    Text.pack (replicate (16 - length rendered) '0' <> rendered)+  Text.pack (replicate (16 - length rendered) '0' <> rendered)   where     rendered = showHex (Text.foldl' step offset logical) ""     offset :: Word64@@ -165,68 +161,64 @@     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.--}+-- | 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-                )+  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).--}+-- | 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)-    }+  { 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).--}+-- | 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}+  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'+      Pool.acquire $+        Pool.Config.settings+          [ Pool.Config.staticConnectionSettings (Conn.connectionString connStr)+          ] -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@.--}+-- | 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)+  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))+  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
@@ -4,1250 +4,1237 @@ {-# 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 (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.Attributes (Attribute (..), Attributes, PrimitiveAttribute (..), lookupAttribute)-import OpenTelemetry.Context qualified as Ctxt-import OpenTelemetry.Context.ThreadLocal qualified as CtxtLocal-import OpenTelemetry.Exporter.InMemory.Span (inMemoryListExporter)-import OpenTelemetry.Processor.Span (SpanProcessor)-import OpenTelemetry.Propagator.W3CTraceContext qualified as W3C-import OpenTelemetry.Trace.Core (Event (..), ImmutableSpan (..), SpanHot (..))-import OpenTelemetry.Trace.Core qualified as OTel-import OpenTelemetry.Trace.Id.Generator.Default (defaultIdGenerator)-import OpenTelemetry.Util (appendOnlyBoundedCollectionValues)-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 = do-    -- PGMQ's schema is installed by appending pgmq-migration's native component to-    -- the suite's framework plan, so one pg-migrate ledger owns kiroku, keiro, and-    -- pgmq together.-    pgmq <- either (fail . show) pure Migration.pgmqMigrations-    Postgres.withMigratedSuiteWith [pgmq] \fixture ->-        hspec $-            describe "Keiro.PGMQ" $-                around (Postgres.withFreshDatabase fixture) spec--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.-Dummy ids cannot encode a valid @traceparent@, which is why the default id-generator is wired in explicitly.--}-mkW3CProvider :: [SpanProcessor] -> IO OTel.TracerProvider-mkW3CProvider processors =-    OTel.createTracerProvider-        processors-        OTel.emptyTracerProviderOptions-            { OTel.tracerProviderOptionsIdGenerator = defaultIdGenerator-            , OTel.tracerProviderOptionsPropagators = W3C.w3cTraceContextPropagator-            }--{- | A W3C provider with no span processors: enough to inspect propagated-headers, not enough to inspect exported spans.--}-setupW3CProvider :: IO OTel.TracerProvider-setupW3CProvider = mkW3CProvider []--{- | A W3C provider whose ended spans are collected in memory. Shut the provider-down (see 'capturedSpans') before reading the reference so every span that was-still open has been flushed.--}-setupCapturingProvider :: IO (OTel.TracerProvider, IORef [ImmutableSpan])-setupCapturingProvider = do-    (processor, spansRef) <- inMemoryListExporter-    provider <- mkW3CProvider [processor]-    pure (provider, spansRef)--{- | Shut the provider down (ending and exporting everything still buffered) and-return a frozen snapshot of every captured span.--}-capturedSpans :: OTel.TracerProvider -> IORef [ImmutableSpan] -> IO [CapturedSpan]-capturedSpans provider spansRef = do-    _ <- OTel.shutdownTracerProvider provider Nothing-    traverse captureSpan =<< readIORef spansRef--{- | A frozen snapshot of an 'ImmutableSpan'. In hs-opentelemetry 1.0 the mutable-span fields (name, attributes, status) live behind the @spanHot :: IORef SpanHot@-field rather than directly on 'ImmutableSpan', so the tests read that reference-once after the span ends and assert on this flat record.--}-data CapturedSpan = CapturedSpan-    { csName :: Text-    , csKind :: OTel.SpanKind-    , csAttributes :: Attributes-    , csStatus :: OTel.SpanStatus-    , csContext :: OTel.SpanContext-    , csParent :: Maybe OTel.Span-    , csEventNames :: [Text]-    }--captureSpan :: ImmutableSpan -> IO CapturedSpan-captureSpan sp = do-    hot <- readIORef (spanHot sp)-    pure-        CapturedSpan-            { csName = hotName hot-            , csKind = spanKind sp-            , csAttributes = hotAttributes hot-            , csStatus = hotStatus hot-            , csContext = spanContext sp-            , csParent = spanParent sp-            , csEventNames =-                map eventName (toList (appendOnlyBoundedCollectionValues (hotEvents hot)))-            }--textAttr :: Attributes -> Text -> Maybe Text-textAttr attrs name = case lookupAttribute attrs name of-    Just (AttributeValue (TextAttribute t)) -> Just t-    _ -> Nothing---- | Every captured span whose name matches exactly.-spansNamed :: Text -> [CapturedSpan] -> [CapturedSpan]-spansNamed name = filter ((== name) . csName)---- | The 'OTel.SpanContext' of a captured span's parent, if it had one.-parentSpanContext :: CapturedSpan -> IO (Maybe OTel.SpanContext)-parentSpanContext = traverse OTel.getSpanContext . csParent--{- | The one @\<jobName\> process@ span a single one-shot delivery must produce.-Anything other than exactly one is a failure that names every captured span, so-a duplicate wrapper is diagnosed rather than silently accepted by taking the-head of the list.--}-theProcessSpan :: Text -> [CapturedSpan] -> IO CapturedSpan-theProcessSpan jobName spans =-    case spansNamed (jobName <> " process") spans of-        [only] -> pure only-        other ->-            fail-                ( "expected exactly one "-                    <> show (jobName <> " process")-                    <> " span, got "-                    <> show (length other)-                    <> "; all captured spans: "-                    <> show (map csName spans)-                )--{- | Run a @Stack@ action against a fresh 'JobRuntime' wired to @tracer@, so both-the shibuya 'Tracing' effect and the @pgmq@ interpreter emit spans. Fails the-test on any PGMQ runtime error, exactly like 'runDb'.--}-runDbTraced :: Text -> OTel.Tracer -> Eff Stack a -> IO a-runDbTraced connStr tracer act =-    withJobRuntime connStr (Just tracer) $ \rt -> do-        res <- runJobEff rt act-        either (\e -> fail ("PGMQ runtime error: " <> show e)) pure res--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-111 M1: the captured-span fixture itself, proven against the spans the-    -- traced pgmq interpreter already emits.-    it "captured tracing fixture sees PGMQ publish and receive spans" $ \connStr -> do-        (provider, spansRef) <- setupCapturingProvider-        let tracer = OTel.makeTracer provider "keiro-pgmq-test" OTel.tracerOptions-            job = mkJob "keiro_pgmq_test.fixture_spans"-            queue = queueNameToText job.jobQueue.physicalName-        runDbTraced connStr tracer $ do-            ensureJobQueue job-            _ <- enqueue job (Ping "fixture" 1)-            _ <- readMessages job.jobQueue.physicalName 1-            pure ()-        spans <- capturedSpans provider spansRef-        map csName spans `shouldSatisfy` elem ("publish " <> queue)-        map csName spans `shouldSatisfy` elem ("receive " <> queue)--    -- EP-111 M2: the central proof — the one-shot process span continues the-    -- producer's trace using only what the PGMQ headers carry.-    it "one-shot process span continues the enqueued W3C parent" $ \connStr -> do-        (provider, spansRef) <- setupCapturingProvider-        let tracer = OTel.makeTracer provider "keiro-pgmq-test" OTel.tracerOptions-            job = mkJob "keiro_pgmq_test.one_shot_parent"-        producerSpan <- OTel.createSpan tracer Ctxt.empty "enqueue" OTel.defaultSpanArguments-        producerCtx <- OTel.getSpanContext producerSpan-        -- Attach the producer span only for the enqueue, then detach it. The-        -- drain therefore has no local parent to inherit: the only path from-        -- producer to consumer is the traceparent stored in the PGMQ headers.-        token <- CtxtLocal.attachContext (Ctxt.insertSpan producerSpan Ctxt.empty)-        runDbTraced connStr tracer $ do-            ensureJobQueue job-            _ <- enqueueTraced provider job (MessageHeaders (object [])) (Ping "traced" 1)-            pure ()-        CtxtLocal.detachContext token-        OTel.endSpan producerSpan Nothing--        drained <--            runDbTraced connStr tracer $-                runJobOnceWithContext defaultJobTuning 1 job \_ctx _payload -> pure Done-        drained `shouldBe` 1--        spans <- capturedSpans provider spansRef-        processSpan <- theProcessSpan job.jobName spans-        csKind processSpan `shouldBe` OTel.Consumer-        OTel.traceId (csContext processSpan) `shouldBe` OTel.traceId producerCtx-        parent <- parentSpanContext processSpan-        fmap OTel.spanId parent `shouldBe` Just (OTel.spanId producerCtx)-        textAttr (csAttributes processSpan) "messaging.system" `shouldBe` Just "shibuya"-        textAttr (csAttributes processSpan) "messaging.destination.name"-            `shouldBe` Just job.jobName-        textAttr (csAttributes processSpan) "messaging.operation.type" `shouldBe` Just "process"-        textAttr (csAttributes processSpan) "messaging.message.id" `shouldSatisfy` \case-            Just _ -> True-            Nothing -> False-        textAttr (csAttributes processSpan) "shibuya.ack.decision" `shouldBe` Just "ack_ok"-        csStatus processSpan `shouldBe` OTel.Ok--    -- EP-111 M3: the branches whose telemetry meaning differs from plain success.-    it "one-shot Retry reports ack_retry with an OK span and hides the row" $ \connStr -> do-        (provider, spansRef) <- setupCapturingProvider-        let tracer = OTel.makeTracer provider "keiro-pgmq-test" OTel.tracerOptions-            job = mkJob "keiro_pgmq_test.span_retry"-        (drained, len, hidden) <--            runDbTraced connStr tracer $ do-                ensureJobQueue job-                _ <- enqueue job (Ping "r" 1)-                drained <--                    runJobOnceWithContext defaultJobTuning 1 job \_ctx _payload ->-                        pure (Retry (RetryDelay 30))-                len <- queueLen job.jobQueue.physicalName-                hidden <- readOneIsEmpty job.jobQueue.physicalName-                pure (drained, len, hidden)-        drained `shouldBe` 1-        len `shouldBe` 1-        hidden `shouldBe` True-        processSpan <- theProcessSpan job.jobName =<< capturedSpans provider spansRef-        textAttr (csAttributes processSpan) "shibuya.ack.decision" `shouldBe` Just "ack_retry"-        csStatus processSpan `shouldBe` OTel.Ok--    it "one-shot Dead reports ack_dead_letter with an ERROR span" $ \connStr -> do-        (provider, spansRef) <- setupCapturingProvider-        let tracer = OTel.makeTracer provider "keiro-pgmq-test" OTel.tracerOptions-            job = mkJob "keiro_pgmq_test.span_dead"-        (mainLen, dlqLen) <--            runDbTraced connStr tracer $ do-                ensureJobQueue job-                _ <- enqueue job (Ping "poison" 1)-                runJobOnce 1 job (\_ -> pure (Dead "bad"))-                mainLen <- queueLen job.jobQueue.physicalName-                dlqLen <- queueLen job.jobQueue.dlqName-                pure (mainLen, dlqLen)-        mainLen `shouldBe` 0-        dlqLen `shouldBe` 1-        processSpan <- theProcessSpan job.jobName =<< capturedSpans provider spansRef-        textAttr (csAttributes processSpan) "shibuya.ack.decision"-            `shouldBe` Just "ack_dead_letter"-        csStatus processSpan `shouldBe` OTel.Error "poison_pill: bad"--    it "an undecodable payload reports ack_dead_letter without a handler-started event" $ \connStr -> do-        (provider, spansRef) <- setupCapturingProvider-        let tracer = OTel.makeTracer provider "keiro-pgmq-test" OTel.tracerOptions-            job = mkJob "keiro_pgmq_test.span_malformed"-        dlqLen <--            runDbTraced connStr tracer $ do-                ensureJobQueue job-                _ <--                    Pgmq.sendMessage-                        SendMessage-                            { queueName = job.jobQueue.physicalName-                            , messageBody = MessageBody (String "not a ping")-                            , delay = Nothing-                            }-                runJobOnce 1 job (\_ -> pure Done)-                queueLen job.jobQueue.dlqName-        dlqLen `shouldBe` 1-        processSpan <- theProcessSpan job.jobName =<< capturedSpans provider spansRef-        textAttr (csAttributes processSpan) "shibuya.ack.decision"-            `shouldBe` Just "ack_dead_letter"-        csStatus processSpan `shouldSatisfy` \case-            OTel.Error reason -> "invalid_payload: " `Text.isPrefixOf` reason-            _ -> False-        csEventNames processSpan `shouldNotSatisfy` elem "shibuya.handler.started"--    it "a thrown handler records an exception and claims no acknowledgement" $ \connStr -> do-        (provider, spansRef) <- setupCapturingProvider-        let tracer = OTel.makeTracer provider "keiro-pgmq-test" OTel.tracerOptions-            job = mkJob "keiro_pgmq_test.span_throw"-        (drained, len, hidden) <--            runDbTraced connStr tracer $ do-                ensureJobQueue job-                _ <- enqueue job (Ping "boom" 1)-                drained <--                    runJobOnceWithContext defaultJobTuning 1 job \_ctx _payload ->-                        liftIO (throwIO (userError "handler exploded"))-                len <- queueLen job.jobQueue.physicalName-                hidden <- readOneIsEmpty job.jobQueue.physicalName-                pure (drained, len, hidden)-        drained `shouldBe` 0-        len `shouldBe` 1-        hidden `shouldBe` True-        processSpan <- theProcessSpan job.jobName =<< capturedSpans provider spansRef-        csEventNames processSpan `shouldSatisfy` elem "shibuya.handler.started"-        csEventNames processSpan `shouldSatisfy` elem "exception"-        csEventNames processSpan `shouldNotSatisfy` elem "shibuya.handler.completed"-        textAttr (csAttributes processSpan) "shibuya.ack.decision" `shouldBe` Nothing-        csStatus processSpan `shouldSatisfy` \case-            OTel.Error reason -> "handler exception: " `Text.isPrefixOf` reason-            _ -> False--    it "a message with no trace headers still gets exactly one process span" $ \connStr -> do-        (provider, spansRef) <- setupCapturingProvider-        let tracer = OTel.makeTracer provider "keiro-pgmq-test" OTel.tracerOptions-            job = mkJob "keiro_pgmq_test.span_no_parent"-        -- Plain 'enqueue' writes no headers at all, so there is no traceparent-        -- to extract and shibuya falls back to the ambient local context.-        runDbTraced connStr tracer $ do-            ensureJobQueue job-            _ <- enqueue job (Ping "plain" 1)-            runJobOnce 1 job (\_ -> pure Done)-        processSpan <- theProcessSpan job.jobName =<< capturedSpans provider spansRef-        csKind processSpan `shouldBe` OTel.Consumer-        textAttr (csAttributes processSpan) "shibuya.ack.decision" `shouldBe` Just "ack_ok"-        csStatus processSpan `shouldBe` OTel.Ok--    it "a FIFO delivery carries shibuya.partition on its process span" $ \connStr -> do-        (provider, spansRef) <- setupCapturingProvider-        let tracer = OTel.makeTracer provider "keiro-pgmq-test" OTel.tracerOptions-            job = mkJob "keiro_pgmq_test.span_partition"-        drained <--            runDbTraced connStr tracer $ do-                ensureOrderedJobQueue job-                _ <- enqueueToGroup job "g1" (Ping "grouped" 1)-                runJobOnceWithContext (withOrdering FifoThroughput defaultJobTuning) 1 job \_ctx _p ->-                    pure Done-        drained `shouldBe` 1-        processSpan <- theProcessSpan job.jobName =<< capturedSpans provider spansRef-        textAttr (csAttributes processSpan) "shibuya.partition" `shouldBe` Just "g1"--    -- 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++-- | 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 (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.Attributes (Attribute (..), Attributes, PrimitiveAttribute (..), lookupAttribute)+import OpenTelemetry.Context qualified as Ctxt+import OpenTelemetry.Context.ThreadLocal qualified as CtxtLocal+import OpenTelemetry.Exporter.InMemory.Span (inMemoryListExporter)+import OpenTelemetry.Processor.Span (SpanProcessor)+import OpenTelemetry.Propagator.W3CTraceContext qualified as W3C+import OpenTelemetry.Trace.Core (Event (..), ImmutableSpan (..), SpanHot (..))+import OpenTelemetry.Trace.Core qualified as OTel+import OpenTelemetry.Trace.Id.Generator.Default (defaultIdGenerator)+import OpenTelemetry.Util (appendOnlyBoundedCollectionValues)+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 = do+  -- PGMQ's schema is installed by appending pgmq-migration's native component to+  -- the suite's framework plan, so one pg-migrate ledger owns kiroku, keiro, and+  -- pgmq together.+  pgmq <- either (fail . show) pure Migration.pgmqMigrations+  Postgres.withMigratedSuiteWith [pgmq] \fixture ->+    hspec $+      describe "Keiro.PGMQ" $+        around (Postgres.withFreshDatabase fixture) spec++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.+-- Dummy ids cannot encode a valid @traceparent@, which is why the default id+-- generator is wired in explicitly.+mkW3CProvider :: [SpanProcessor] -> IO OTel.TracerProvider+mkW3CProvider processors =+  OTel.createTracerProvider+    processors+    OTel.emptyTracerProviderOptions+      { OTel.tracerProviderOptionsIdGenerator = defaultIdGenerator,+        OTel.tracerProviderOptionsPropagators = W3C.w3cTraceContextPropagator+      }++-- | A W3C provider with no span processors: enough to inspect propagated+-- headers, not enough to inspect exported spans.+setupW3CProvider :: IO OTel.TracerProvider+setupW3CProvider = mkW3CProvider []++-- | A W3C provider whose ended spans are collected in memory. Shut the provider+-- down (see 'capturedSpans') before reading the reference so every span that was+-- still open has been flushed.+setupCapturingProvider :: IO (OTel.TracerProvider, IORef [ImmutableSpan])+setupCapturingProvider = do+  (processor, spansRef) <- inMemoryListExporter+  provider <- mkW3CProvider [processor]+  pure (provider, spansRef)++-- | Shut the provider down (ending and exporting everything still buffered) and+-- return a frozen snapshot of every captured span.+capturedSpans :: OTel.TracerProvider -> IORef [ImmutableSpan] -> IO [CapturedSpan]+capturedSpans provider spansRef = do+  _ <- OTel.shutdownTracerProvider provider Nothing+  traverse captureSpan =<< readIORef spansRef++-- | A frozen snapshot of an 'ImmutableSpan'. In hs-opentelemetry 1.0 the mutable+-- span fields (name, attributes, status) live behind the @spanHot :: IORef SpanHot@+-- field rather than directly on 'ImmutableSpan', so the tests read that reference+-- once after the span ends and assert on this flat record.+data CapturedSpan = CapturedSpan+  { csName :: Text,+    csKind :: OTel.SpanKind,+    csAttributes :: Attributes,+    csStatus :: OTel.SpanStatus,+    csContext :: OTel.SpanContext,+    csParent :: Maybe OTel.Span,+    csEventNames :: [Text]+  }++captureSpan :: ImmutableSpan -> IO CapturedSpan+captureSpan sp = do+  hot <- readIORef (spanHot sp)+  pure+    CapturedSpan+      { csName = hotName hot,+        csKind = spanKind sp,+        csAttributes = hotAttributes hot,+        csStatus = hotStatus hot,+        csContext = spanContext sp,+        csParent = spanParent sp,+        csEventNames =+          map eventName (toList (appendOnlyBoundedCollectionValues (hotEvents hot)))+      }++textAttr :: Attributes -> Text -> Maybe Text+textAttr attrs name = case lookupAttribute attrs name of+  Just (AttributeValue (TextAttribute t)) -> Just t+  _ -> Nothing++-- | Every captured span whose name matches exactly.+spansNamed :: Text -> [CapturedSpan] -> [CapturedSpan]+spansNamed name = filter ((== name) . csName)++-- | The 'OTel.SpanContext' of a captured span's parent, if it had one.+parentSpanContext :: CapturedSpan -> IO (Maybe OTel.SpanContext)+parentSpanContext = traverse OTel.getSpanContext . csParent++-- | The one @\<jobName\> process@ span a single one-shot delivery must produce.+-- Anything other than exactly one is a failure that names every captured span, so+-- a duplicate wrapper is diagnosed rather than silently accepted by taking the+-- head of the list.+theProcessSpan :: Text -> [CapturedSpan] -> IO CapturedSpan+theProcessSpan jobName spans =+  case spansNamed (jobName <> " process") spans of+    [only] -> pure only+    other ->+      fail+        ( "expected exactly one "+            <> show (jobName <> " process")+            <> " span, got "+            <> show (length other)+            <> "; all captured spans: "+            <> show (map csName spans)+        )++-- | Run a @Stack@ action against a fresh 'JobRuntime' wired to @tracer@, so both+-- the shibuya 'Tracing' effect and the @pgmq@ interpreter emit spans. Fails the+-- test on any PGMQ runtime error, exactly like 'runDb'.+runDbTraced :: Text -> OTel.Tracer -> Eff Stack a -> IO a+runDbTraced connStr tracer act =+  withJobRuntime connStr (Just tracer) $ \rt -> do+    res <- runJobEff rt act+    either (\e -> fail ("PGMQ runtime error: " <> show e)) pure res++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-111 M1: the captured-span fixture itself, proven against the spans the+  -- traced pgmq interpreter already emits.+  it "captured tracing fixture sees PGMQ publish and receive spans" $ \connStr -> do+    (provider, spansRef) <- setupCapturingProvider+    let tracer = OTel.makeTracer provider "keiro-pgmq-test" OTel.tracerOptions+        job = mkJob "keiro_pgmq_test.fixture_spans"+        queue = queueNameToText job.jobQueue.physicalName+    runDbTraced connStr tracer $ do+      ensureJobQueue job+      _ <- enqueue job (Ping "fixture" 1)+      _ <- readMessages job.jobQueue.physicalName 1+      pure ()+    spans <- capturedSpans provider spansRef+    map csName spans `shouldSatisfy` elem ("publish " <> queue)+    map csName spans `shouldSatisfy` elem ("receive " <> queue)++  -- EP-111 M2: the central proof — the one-shot process span continues the+  -- producer's trace using only what the PGMQ headers carry.+  it "one-shot process span continues the enqueued W3C parent" $ \connStr -> do+    (provider, spansRef) <- setupCapturingProvider+    let tracer = OTel.makeTracer provider "keiro-pgmq-test" OTel.tracerOptions+        job = mkJob "keiro_pgmq_test.one_shot_parent"+    producerSpan <- OTel.createSpan tracer Ctxt.empty "enqueue" OTel.defaultSpanArguments+    producerCtx <- OTel.getSpanContext producerSpan+    -- Attach the producer span only for the enqueue, then detach it. The+    -- drain therefore has no local parent to inherit: the only path from+    -- producer to consumer is the traceparent stored in the PGMQ headers.+    token <- CtxtLocal.attachContext (Ctxt.insertSpan producerSpan Ctxt.empty)+    runDbTraced connStr tracer $ do+      ensureJobQueue job+      _ <- enqueueTraced provider job (MessageHeaders (object [])) (Ping "traced" 1)+      pure ()+    CtxtLocal.detachContext token+    OTel.endSpan producerSpan Nothing++    drained <-+      runDbTraced connStr tracer $+        runJobOnceWithContext defaultJobTuning 1 job \_ctx _payload -> pure Done+    drained `shouldBe` 1++    spans <- capturedSpans provider spansRef+    processSpan <- theProcessSpan job.jobName spans+    csKind processSpan `shouldBe` OTel.Consumer+    OTel.traceId (csContext processSpan) `shouldBe` OTel.traceId producerCtx+    parent <- parentSpanContext processSpan+    fmap OTel.spanId parent `shouldBe` Just (OTel.spanId producerCtx)+    textAttr (csAttributes processSpan) "messaging.system" `shouldBe` Just "shibuya"+    textAttr (csAttributes processSpan) "messaging.destination.name"+      `shouldBe` Just job.jobName+    textAttr (csAttributes processSpan) "messaging.operation.type" `shouldBe` Just "process"+    textAttr (csAttributes processSpan) "messaging.message.id" `shouldSatisfy` \case+      Just _ -> True+      Nothing -> False+    textAttr (csAttributes processSpan) "shibuya.ack.decision" `shouldBe` Just "ack_ok"+    csStatus processSpan `shouldBe` OTel.Ok++  -- EP-111 M3: the branches whose telemetry meaning differs from plain success.+  it "one-shot Retry reports ack_retry with an OK span and hides the row" $ \connStr -> do+    (provider, spansRef) <- setupCapturingProvider+    let tracer = OTel.makeTracer provider "keiro-pgmq-test" OTel.tracerOptions+        job = mkJob "keiro_pgmq_test.span_retry"+    (drained, len, hidden) <-+      runDbTraced connStr tracer $ do+        ensureJobQueue job+        _ <- enqueue job (Ping "r" 1)+        drained <-+          runJobOnceWithContext defaultJobTuning 1 job \_ctx _payload ->+            pure (Retry (RetryDelay 30))+        len <- queueLen job.jobQueue.physicalName+        hidden <- readOneIsEmpty job.jobQueue.physicalName+        pure (drained, len, hidden)+    drained `shouldBe` 1+    len `shouldBe` 1+    hidden `shouldBe` True+    processSpan <- theProcessSpan job.jobName =<< capturedSpans provider spansRef+    textAttr (csAttributes processSpan) "shibuya.ack.decision" `shouldBe` Just "ack_retry"+    csStatus processSpan `shouldBe` OTel.Ok++  it "one-shot Dead reports ack_dead_letter with an ERROR span" $ \connStr -> do+    (provider, spansRef) <- setupCapturingProvider+    let tracer = OTel.makeTracer provider "keiro-pgmq-test" OTel.tracerOptions+        job = mkJob "keiro_pgmq_test.span_dead"+    (mainLen, dlqLen) <-+      runDbTraced connStr tracer $ do+        ensureJobQueue job+        _ <- enqueue job (Ping "poison" 1)+        runJobOnce 1 job (\_ -> pure (Dead "bad"))+        mainLen <- queueLen job.jobQueue.physicalName+        dlqLen <- queueLen job.jobQueue.dlqName+        pure (mainLen, dlqLen)+    mainLen `shouldBe` 0+    dlqLen `shouldBe` 1+    processSpan <- theProcessSpan job.jobName =<< capturedSpans provider spansRef+    textAttr (csAttributes processSpan) "shibuya.ack.decision"+      `shouldBe` Just "ack_dead_letter"+    csStatus processSpan `shouldBe` OTel.Error "poison_pill: bad"++  it "an undecodable payload reports ack_dead_letter without a handler-started event" $ \connStr -> do+    (provider, spansRef) <- setupCapturingProvider+    let tracer = OTel.makeTracer provider "keiro-pgmq-test" OTel.tracerOptions+        job = mkJob "keiro_pgmq_test.span_malformed"+    dlqLen <-+      runDbTraced connStr tracer $ do+        ensureJobQueue job+        _ <-+          Pgmq.sendMessage+            SendMessage+              { queueName = job.jobQueue.physicalName,+                messageBody = MessageBody (String "not a ping"),+                delay = Nothing+              }+        runJobOnce 1 job (\_ -> pure Done)+        queueLen job.jobQueue.dlqName+    dlqLen `shouldBe` 1+    processSpan <- theProcessSpan job.jobName =<< capturedSpans provider spansRef+    textAttr (csAttributes processSpan) "shibuya.ack.decision"+      `shouldBe` Just "ack_dead_letter"+    csStatus processSpan `shouldSatisfy` \case+      OTel.Error reason -> "invalid_payload: " `Text.isPrefixOf` reason+      _ -> False+    csEventNames processSpan `shouldNotSatisfy` elem "shibuya.handler.started"++  it "a thrown handler records an exception and claims no acknowledgement" $ \connStr -> do+    (provider, spansRef) <- setupCapturingProvider+    let tracer = OTel.makeTracer provider "keiro-pgmq-test" OTel.tracerOptions+        job = mkJob "keiro_pgmq_test.span_throw"+    (drained, len, hidden) <-+      runDbTraced connStr tracer $ do+        ensureJobQueue job+        _ <- enqueue job (Ping "boom" 1)+        drained <-+          runJobOnceWithContext defaultJobTuning 1 job \_ctx _payload ->+            liftIO (throwIO (userError "handler exploded"))+        len <- queueLen job.jobQueue.physicalName+        hidden <- readOneIsEmpty job.jobQueue.physicalName+        pure (drained, len, hidden)+    drained `shouldBe` 0+    len `shouldBe` 1+    hidden `shouldBe` True+    processSpan <- theProcessSpan job.jobName =<< capturedSpans provider spansRef+    csEventNames processSpan `shouldSatisfy` elem "shibuya.handler.started"+    csEventNames processSpan `shouldSatisfy` elem "exception"+    csEventNames processSpan `shouldNotSatisfy` elem "shibuya.handler.completed"+    textAttr (csAttributes processSpan) "shibuya.ack.decision" `shouldBe` Nothing+    csStatus processSpan `shouldSatisfy` \case+      OTel.Error reason -> "handler exception: " `Text.isPrefixOf` reason+      _ -> False++  it "a message with no trace headers still gets exactly one process span" $ \connStr -> do+    (provider, spansRef) <- setupCapturingProvider+    let tracer = OTel.makeTracer provider "keiro-pgmq-test" OTel.tracerOptions+        job = mkJob "keiro_pgmq_test.span_no_parent"+    -- Plain 'enqueue' writes no headers at all, so there is no traceparent+    -- to extract and shibuya falls back to the ambient local context.+    runDbTraced connStr tracer $ do+      ensureJobQueue job+      _ <- enqueue job (Ping "plain" 1)+      runJobOnce 1 job (\_ -> pure Done)+    processSpan <- theProcessSpan job.jobName =<< capturedSpans provider spansRef+    csKind processSpan `shouldBe` OTel.Consumer+    textAttr (csAttributes processSpan) "shibuya.ack.decision" `shouldBe` Just "ack_ok"+    csStatus processSpan `shouldBe` OTel.Ok++  it "a FIFO delivery carries shibuya.partition on its process span" $ \connStr -> do+    (provider, spansRef) <- setupCapturingProvider+    let tracer = OTel.makeTracer provider "keiro-pgmq-test" OTel.tracerOptions+        job = mkJob "keiro_pgmq_test.span_partition"+    drained <-+      runDbTraced connStr tracer $ do+        ensureOrderedJobQueue job+        _ <- enqueueToGroup job "g1" (Ping "grouped" 1)+        runJobOnceWithContext (withOrdering FifoThroughput defaultJobTuning) 1 job \_ctx _p ->+          pure Done+    drained `shouldBe` 1+    processSpan <- theProcessSpan job.jobName =<< capturedSpans provider spansRef+    textAttr (csAttributes processSpan) "shibuya.partition" `shouldBe` Just "g1"++  -- 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