packages feed

keiro-pgmq 0.16.0.0 → 0.17.0.0

raw patch · 6 files changed

+892/−141 lines, 6 filesdep ~keiro-coredep ~keiro-test-supportdep ~pgmq-configPVP ok

version bump matches the API change (PVP)

Dependency ranges changed: keiro-core, keiro-test-support, pgmq-config, pgmq-core, pgmq-effectful, pgmq-hasql, pgmq-migration, shibuya-pgmq-adapter

API changes (from Hackage documentation)

+ Keiro.PGMQ.Dlq: PurgeDlqBlocked :: !Int64 -> PurgeDlqResult
+ Keiro.PGMQ.Dlq: PurgeDlqPurged :: !Int64 -> PurgeDlqResult
+ Keiro.PGMQ.Dlq: [originalHeaders] :: DlqEntry p -> !Maybe Value
+ Keiro.PGMQ.Dlq: archiveDlqEntries :: forall (es :: [Effect]) p. (Pgmq :> es, IOE :> es) => Job p -> [MessageId] -> Eff es [MessageId]
+ Keiro.PGMQ.Dlq: data PurgeDlqResult
+ Keiro.PGMQ.Dlq: instance GHC.Classes.Eq Keiro.PGMQ.Dlq.PurgeDlqResult
+ Keiro.PGMQ.Dlq: instance GHC.Internal.Show.Show Keiro.PGMQ.Dlq.PurgeDlqResult
+ Keiro.PGMQ.Dlq: purgeDlqForce :: forall (es :: [Effect]) p. (Pgmq :> es, IOE :> es) => Job p -> Eff es Int64
+ Keiro.PGMQ.Job: EmptyPartitionInterval :: PartitionSpecConfigError
+ Keiro.PGMQ.Job: EmptyRetentionInterval :: PartitionSpecConfigError
+ Keiro.PGMQ.Job: FifoHeads :: JobOrdering
+ Keiro.PGMQ.Job: InvalidJobTuning :: !JobTuningConfigError -> JobConsumptionConfigError
+ Keiro.PGMQ.Job: JobOrderingMismatch :: !JobOrdering -> !JobOrdering -> JobConsumptionConfigError
+ Keiro.PGMQ.Job: MixedPartitionUnits :: !Text -> !Text -> PartitionSpecConfigError
+ Keiro.PGMQ.Job: NonPositivePartitionInterval :: !Integer -> PartitionSpecConfigError
+ Keiro.PGMQ.Job: NonPositiveRetentionInterval :: !Integer -> PartitionSpecConfigError
+ Keiro.PGMQ.Job: PartitionIntervalOutsidePostgresInteger :: !Integer -> PartitionSpecConfigError
+ Keiro.PGMQ.Job: RetentionBelowPartitionInterval :: !Integer -> !Integer -> PartitionSpecConfigError
+ Keiro.PGMQ.Job: UnsafeLegacyFifoBatch :: !JobOrdering -> !Int32 -> JobConsumptionConfigError
+ Keiro.PGMQ.Job: [jobOrderingDeclared] :: JobConsumptionConfigError -> !JobOrdering
+ Keiro.PGMQ.Job: [jobOrdering] :: Job p -> !JobOrdering
+ Keiro.PGMQ.Job: [tuningOrderingGiven] :: JobConsumptionConfigError -> !JobOrdering
+ Keiro.PGMQ.Job: [unsafeBatchSize] :: JobConsumptionConfigError -> !Int32
+ Keiro.PGMQ.Job: [unsafeOrdering] :: JobConsumptionConfigError -> !JobOrdering
+ Keiro.PGMQ.Job: data JobConsumptionConfigError
+ Keiro.PGMQ.Job: data PartitionSpecConfigError
+ Keiro.PGMQ.Job: instance GHC.Classes.Eq Keiro.PGMQ.Job.JobConsumptionConfigError
+ Keiro.PGMQ.Job: instance GHC.Classes.Eq Keiro.PGMQ.Job.PartitionSpecConfigError
+ Keiro.PGMQ.Job: instance GHC.Internal.Exception.Type.Exception Keiro.PGMQ.Job.JobConsumptionConfigError
+ Keiro.PGMQ.Job: instance GHC.Internal.Show.Show Keiro.PGMQ.Job.JobConsumptionConfigError
+ Keiro.PGMQ.Job: instance GHC.Internal.Show.Show Keiro.PGMQ.Job.PartitionSpecConfigError
+ Keiro.PGMQ.Job: mkPartitionSpec :: Text -> Text -> Either PartitionSpecConfigError PartitionSpec
+ Keiro.PGMQ.Metrics: [defaultPartitionLength] :: QueueMetrics -> !Maybe Int64
- Keiro.PGMQ.Dlq: DlqEntry :: !MessageId -> !Text -> !Either JobDecodeError p -> !Maybe Int64 -> !Maybe UTCTime -> !Maybe Int64 -> !Value -> DlqEntry p
+ Keiro.PGMQ.Dlq: DlqEntry :: !MessageId -> !Text -> !Either JobDecodeError p -> !Maybe Int64 -> !Maybe UTCTime -> !Maybe Int64 -> !Maybe Value -> !Value -> DlqEntry p
- Keiro.PGMQ.Dlq: purgeDlq :: forall (es :: [Effect]) p. (Pgmq :> es, IOE :> es) => Job p -> Eff es ()
+ Keiro.PGMQ.Dlq: purgeDlq :: forall (es :: [Effect]) p. (Pgmq :> es, IOE :> es) => Job p -> Eff es PurgeDlqResult
- Keiro.PGMQ.Job: Job :: !Text -> !QueueRef -> !JobCodec p -> !RetryPolicy -> Job p
+ Keiro.PGMQ.Job: Job :: !Text -> !QueueRef -> !JobCodec p -> !JobOrdering -> !RetryPolicy -> Job p
- Keiro.PGMQ.Metrics: QueueMetrics :: !Text -> !Int64 -> !Maybe Int32 -> !Maybe Int32 -> !Int64 -> !UTCTime -> !Int64 -> QueueMetrics
+ Keiro.PGMQ.Metrics: QueueMetrics :: !Text -> !Int64 -> !Maybe Int32 -> !Maybe Int32 -> !Int64 -> !UTCTime -> !Int64 -> !Maybe Int64 -> QueueMetrics

Files

CHANGELOG.md view
@@ -6,6 +6,51 @@  ## [Unreleased] +## 0.17.0.0 — 2026-09-17++### Breaking Changes++- `DlqEntry` now exposes `originalHeaders`; external record construction and+  exhaustive positional matches must account for the new field. `purgeDlq` now+  returns `PurgeDlqResult` instead of `()`. Callers that discard the result may+  still compile but must inspect `PurgeDlqBlocked` rather than assuming deletion.+- Require the released `pgmq-config`, `pgmq-core`, `pgmq-effectful`,+  `pgmq-hasql`, and test-only `pgmq-migration` 0.6 family together with+  `shibuya-pgmq-adapter ^>=0.16.0.0`. The re-exported `QueueMetrics` record now+  includes nullable `defaultPartitionLength`, so callers that construct it or+  match it positionally must handle the new field. Partitioned provisioning+  deliberately passes `premake = Nothing` to retain the PGMQ server default+  rather than exposing explicit premake control.+- `Job` gains the required `jobOrdering` field. Explicit tuning must match the+  declaration; invalid raw tuning and legacy FIFO batches larger than one now+  throw `JobConsumptionConfigError` before adapter construction or reads.++### New Features++- Add `FifoHeads`, backed by PGMQ's grouped-head read in both worker and+  one-shot paths. It safely batches independent absolute group heads while a+  failed, invisible, or delayed head blocks successors in its own group.+- Add `mkPartitionSpec` and `PartitionSpecConfigError` to reject obvious empty,+  mixed-unit, non-positive, out-of-range, and too-short numeric partition+  settings before database access. The raw constructor remains available for+  server-specific values.++- DLQ redrive preserves the wrapper's original producer headers, including FIFO,+  trace, and application metadata. Missing and JSON-null legacy headers remain+  headerless and never fall back to the DLQ row's own headers.+- `archiveDlqEntries` archives known message ids even while inspection has hidden+  them. Ordinary `purgeDlq` refuses when metrics report hidden rows and returns+  the deleted or blocked count; `purgeDlqForce` is the distinctly named+  unconditional escape hatch.++### Other Changes++- Clarify that partition retention drops whole active and archive partitions and+  can remove unprocessed work after a sufficiently long outage or backlog.+- Describe FIFO provisioning as PGMQ's conventional name-detected GIN index.+  No supplemental grouped-read index is recommended pending reproducible+  full-workload measurements, and Keiro installs no experimental startup DDL.+ ## 0.16.0.0 — 2026-09-07  ### Other Changes
keiro-pgmq.cabal view
@@ -1,7 +1,7 @@-cabal-version:   3.0-name:            keiro-pgmq-version:         0.16.0.0-synopsis:        PostgreSQL job-queue (PGMQ) integration for Keiro+cabal-version: 3.0+name: keiro-pgmq+version: 0.17.0.0+synopsis: PostgreSQL job-queue (PGMQ) integration for Keiro description:   A typed background-job queue for Keiro applications on top of PGMQ (the   PostgreSQL-native message queue) and shibuya (a Broadway-style worker@@ -9,29 +9,34 @@   retry/dead-letter policy, then write a plain domain handler of type   @p -> Eff es JobOutcome@. -license:         BSD-3-Clause-license-file:    LICENSE-author:          Nadeem Bitar-maintainer:      nadeem@gmail.com-copyright:       2026 Nadeem Bitar-category:        Control-homepage:        https://github.com/shinzui/keiro#readme-bug-reports:     https://github.com/shinzui/keiro/issues-build-type:      Simple-tested-with:     GHC >=9.12 && <9.13+license: BSD-3-Clause+license-file: LICENSE+author: Nadeem Bitar+maintainer: nadeem@gmail.com+copyright: 2026 Nadeem Bitar+category: Control+homepage: https://github.com/shinzui/keiro#readme+bug-reports: https://github.com/shinzui/keiro/issues+build-type: Simple+tested-with: ghc >=9.12 && <9.13 extra-doc-files: CHANGELOG.md  source-repository head-  type:     git+  type: git   location: https://github.com/shinzui/keiro.git  common warnings   ghc-options:-    -Wall -Wcompat -Widentities -Wincomplete-record-updates-    -Wincomplete-uni-patterns -Wpartial-fields -Wredundant-constraints+    -Wall+    -Wcompat+    -Widentities+    -Wincomplete-record-updates+    -Wincomplete-uni-patterns+    -Wpartial-fields+    -Wredundant-constraints  common shared-  default-language:   GHC2024+  default-language: GHC2024   default-extensions:     DeriveAnyClass     DuplicateRecordFields@@ -43,7 +48,7 @@     PackageImports  library-  import:          warnings, shared+  import: warnings, shared   exposed-modules:     Keiro.PGMQ     Keiro.PGMQ.Codec@@ -52,49 +57,53 @@     Keiro.PGMQ.Metrics     Keiro.PGMQ.Runtime -  hs-source-dirs:  src+  hs-source-dirs: src   build-depends:-    , aeson                 >=2.2       && <2.3-    , base                  >=4.21      && <5-    , effectful-core        >=2.6       && <2.7-    , hasql                 >=1.10      && <1.11-    , hasql-pool            >=1.2       && <1.5-    , hs-opentelemetry-api  >=1.0       && <1.1-    , keiro-core            ^>=0.16.0.0-    , pgmq-config           >=0.5       && <0.6-    , pgmq-core             >=0.5       && <0.6-    , pgmq-effectful        >=0.5       && <0.6-    , pgmq-hasql            >=0.5       && <0.6-    , shibuya-core          ^>=0.9.0.0-    , shibuya-pgmq-adapter  ^>=0.14.0.0-    , streamly-core         >=0.3       && <0.4-    , text                  >=2.1       && <2.2-    , time                  >=1.12      && <1.15+    aeson >=2.2 && <2.3,+    base >=4.21 && <5,+    effectful-core >=2.6 && <2.7,+    hasql >=1.10 && <1.11,+    hasql-pool >=1.2 && <1.5,+    hs-opentelemetry-api >=1.0 && <1.1,+    keiro-core ^>=0.17.0.0,+    pgmq-config >=0.6 && <0.7,+    pgmq-core >=0.6 && <0.7,+    pgmq-effectful >=0.6 && <0.7,+    pgmq-hasql >=0.6 && <0.7,+    shibuya-core ^>=0.9.0.0,+    shibuya-pgmq-adapter ^>=0.16.0.0,+    streamly-core >=0.3 && <0.4,+    text >=2.1 && <2.2,+    time >=1.12 && <1.15,  test-suite keiro-pgmq-test-  import:         warnings, shared-  type:           exitcode-stdio-1.0+  import: warnings, shared+  type: exitcode-stdio-1.0   hs-source-dirs: test-  main-is:        Main.hs-  ghc-options:    -threaded -rtsopts -with-rtsopts=-N+  main-is: Main.hs+  ghc-options:+    -threaded+    -rtsopts+    -with-rtsopts=-N+   build-depends:-    , aeson                                >=2.2       && <2.3-    , base                                 >=4.21      && <5-    , effectful-core                       >=2.6       && <2.7-    , hasql                                >=1.10      && <1.11-    , hasql-pool                           >=1.2       && <1.5-    , hs-opentelemetry-api                 >=1.0       && <1.1-    , hs-opentelemetry-exporter-in-memory  >=1.0       && <1.1-    , hs-opentelemetry-propagator-w3c      >=1.0       && <1.1-    , hs-opentelemetry-sdk                 >=1.0       && <1.1-    , hspec                                >=2.11-    , keiro-core                           ^>=0.16.0.0-    , keiro-pgmq-    , keiro-test-support                   ^>=0.16.0.0-    , pgmq-config                          >=0.5       && <0.6-    , pgmq-core                            >=0.5       && <0.6-    , pgmq-effectful                       >=0.5       && <0.6-    , pgmq-migration                       >=0.5       && <0.6-    , shibuya-core                         ^>=0.9.0.0-    , shibuya-pgmq-adapter                 ^>=0.14.0.0-    , text                                 >=2.1       && <2.2+    aeson >=2.2 && <2.3,+    base >=4.21 && <5,+    effectful-core >=2.6 && <2.7,+    hasql >=1.10 && <1.11,+    hasql-pool >=1.2 && <1.5,+    hs-opentelemetry-api >=1.0 && <1.1,+    hs-opentelemetry-exporter-in-memory >=1.0 && <1.1,+    hs-opentelemetry-propagator-w3c >=1.0 && <1.1,+    hs-opentelemetry-sdk >=1.0 && <1.1,+    hspec >=2.11,+    keiro-core ^>=0.17.0.0,+    keiro-pgmq,+    keiro-test-support ^>=0.17.0.0,+    pgmq-config >=0.6 && <0.7,+    pgmq-core >=0.6 && <0.7,+    pgmq-effectful >=0.6 && <0.7,+    pgmq-migration >=0.6 && <0.7,+    shibuya-core ^>=0.9.0.0,+    shibuya-pgmq-adapter ^>=0.16.0.0,+    text >=2.1 && <2.2,
src/Keiro/PGMQ/Dlq.hs view
@@ -12,23 +12,46 @@ -- 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'.+-- 'readDlq', 'redriveDlq', and count-based 'archiveDlq' read visible rows and hide+-- each row they inspect for 30 seconds. To retain inspected rows without waiting,+-- keep their 'dlqMessageId' values and pass them to 'archiveDlqEntries'. Verify+-- that every requested id was returned before considering retention complete: ----- 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.+-- @+-- entries <- readDlq job 100+-- let requested = fmap dlqMessageId entries+-- archived <- archiveDlqEntries job requested+-- if Set.fromList archived /= Set.fromList requested+--   then stopAndInvestigate+--   else purgeDlq job+-- @+--+-- PGMQ does not expire ordinary DLQ rows by itself. Archiving moves active rows+-- into @pgmq.a_<dlq>@ for audit. Partitioned archive retention remains subject to+-- its configured partition maintenance; archiving does not promise indefinite+-- retention. 'purgeDlq' deletes only after a metrics snapshot reports no hidden+-- rows, returning 'PurgeDlqBlocked' otherwise. The snapshot and deletion are not+-- atomic. For a full-queue audit, pause producers and other operators, inspect and+-- archive every row that requires retention, verify the active depth is zero, and+-- only then consider purge. A bounded read followed by purge can still delete+-- uninspected visible rows. 'purgeDlqForce' is the explicitly unconditional escape+-- hatch and permanently deletes hidden rows too. Alert on DLQ depth via+-- 'Keiro.PGMQ.Metrics.jobDlqMetrics'.+--+-- Redrive preserves the wrapper's original producer headers, including FIFO group+-- and trace metadata, but it sends a new main-queue row with a new id, a fresh read+-- count, and a new position at the back of its group. Redrive is at-least-once: a+-- crash after sending the original payload but before deleting the DLQ row leaves+-- the payload in both places. Handlers must therefore be idempotent. module Keiro.PGMQ.Dlq   ( DlqEntry (..),     readDlq,     redriveDlq,+    PurgeDlqResult (..),     purgeDlq,+    purgeDlqForce,     archiveDlq,+    archiveDlqEntries,     archiveDlqEntry,     archiveDlqEntryById,   )@@ -44,13 +67,17 @@ import "base" Data.Int (Int32, Int64) import "effectful-core" Effectful (Eff, IOE, (:>)) import "pgmq-effectful" Pgmq.Effectful-  ( Message (..),+  ( BatchMessageQuery (..),+    Message (..),     MessageBody (..),+    MessageHeaders (..),     MessageId (..),     MessageQuery (..),     Pgmq,+    QueueMetrics (..),     ReadMessage (..),     SendMessage (..),+    SendMessageWithHeaders (..),   ) import "pgmq-effectful" Pgmq.Effectful qualified as Pgmq import "text" Data.Text (Text)@@ -68,6 +95,8 @@     originalMessageId :: !(Maybe Int64),     originalEnqueuedAt :: !(Maybe UTCTime),     readCount :: !(Maybe Int64),+    -- | The producer headers preserved in the DLQ wrapper, when present.+    originalHeaders :: !(Maybe Value),     -- | Full DLQ wrapper for forensics.     rawBody :: !Value   }@@ -78,7 +107,8 @@     deadLetterReason :: !Text,     envelopeOriginalMessageId :: !(Maybe Int64),     envelopeOriginalEnqueuedAt :: !(Maybe UTCTime),-    envelopeReadCount :: !(Maybe Int64)+    envelopeReadCount :: !(Maybe Int64),+    envelopeOriginalHeaders :: !(Maybe Value)   }  parseDlqEnvelope :: Value -> Either Text DlqEnvelope@@ -93,13 +123,15 @@         envelopeOriginalMessageId <- obj .:? "original_message_id"         envelopeOriginalEnqueuedAt <- obj .:? "original_enqueued_at"         envelopeReadCount <- obj .:? "read_count"+        envelopeOriginalHeaders <- obj .:? "original_headers"         pure           DlqEnvelope             { originalMessage,               deadLetterReason,               envelopeOriginalMessageId,               envelopeOriginalEnqueuedAt,-              envelopeReadCount+              envelopeReadCount,+              envelopeOriginalHeaders             }      firstText = \case@@ -134,6 +166,7 @@               originalMessageId = Nothing,               originalEnqueuedAt = Nothing,               readCount = Nothing,+              originalHeaders = Nothing,               rawBody = body             }         Right envelope ->@@ -144,12 +177,14 @@               originalMessageId = envelope.envelopeOriginalMessageId,               originalEnqueuedAt = envelope.envelopeOriginalEnqueuedAt,               readCount = envelope.envelopeReadCount,+              originalHeaders = envelope.envelopeOriginalHeaders,               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@ visible DLQ rows back to the main queue. Each read hides the+-- DLQ row for 30 seconds. Redriven messages preserve wrapper producer headers but+-- receive a new id, queue position, and fresh PGMQ @read_ct@. Malformed 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@@ -179,13 +214,22 @@         Left _err ->           pure count         Right envelope -> do-          _ <--            Pgmq.sendMessage-              SendMessage-                { queueName = job.jobQueue.physicalName,-                  messageBody = MessageBody envelope.originalMessage,-                  delay = Nothing-                }+          _ <- case envelope.envelopeOriginalHeaders of+            Just headers ->+              Pgmq.sendMessageWithHeaders+                SendMessageWithHeaders+                  { queueName = job.jobQueue.physicalName,+                    messageBody = MessageBody envelope.originalMessage,+                    messageHeaders = MessageHeaders headers,+                    delay = Nothing+                  }+            Nothing ->+              Pgmq.sendMessage+                SendMessage+                  { queueName = job.jobQueue.physicalName,+                    messageBody = MessageBody envelope.originalMessage,+                    delay = Nothing+                  }           void $             Pgmq.deleteMessage               MessageQuery@@ -194,12 +238,32 @@                 }           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)+-- | Result of a visibility-safe DLQ purge attempt.+data PurgeDlqResult+  = -- | The DLQ had no hidden rows at the metrics snapshot and was purged.+    PurgeDlqPurged !Int64+  | -- | Purge was refused because this many rows were hidden at the snapshot.+    PurgeDlqBlocked !Int64+  deriving stock (Eq, Show) --- | Archive (retain) up to @n@ DLQ rows: move each out of the active DLQ table+-- | Delete all rows currently in the DLQ only when none are hidden by a prior+-- read. The metrics check and deletion are separate operations, so callers that+-- need a full-queue audit must also quiesce concurrent readers and writers.+purgeDlq :: (Pgmq :> es, IOE :> es) => Job p -> Eff es PurgeDlqResult+purgeDlq job = do+  metrics <- Pgmq.queueMetrics job.jobQueue.dlqName+  let invisible = metrics.queueLength - metrics.queueVisibleLength+  if invisible > 0+    then pure (PurgeDlqBlocked invisible)+    else PurgeDlqPurged <$> Pgmq.deleteAllMessagesFromQueue job.jobQueue.dlqName++-- | Unconditionally delete every active DLQ row, including hidden rows.+purgeDlqForce :: (Pgmq :> es, IOE :> es) => Job p -> Eff es Int64+purgeDlqForce job =+  Pgmq.deleteAllMessagesFromQueue job.jobQueue.dlqName++-- | Archive (retain) up to @n@ visible DLQ rows, hiding each inspected row for+-- 30 seconds: 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@@ -231,6 +295,17 @@     archiveOne count message = do       moved <- archiveDlqEntry job message.messageId       pure (if moved then count + 1 else count)++-- | Archive the specified DLQ rows, including rows hidden by a prior read.+-- Returns only ids that were present and moved; returned order is unspecified.+archiveDlqEntries :: (Pgmq :> es, IOE :> es) => Job p -> [MessageId] -> Eff es [MessageId]+archiveDlqEntries _job [] = pure []+archiveDlqEntries job messageIds =+  Pgmq.batchArchiveMessages+    BatchMessageQuery+      { queueName = job.jobQueue.dlqName,+        messageIds+      }  -- | 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
src/Keiro/PGMQ/Job.hs view
@@ -7,7 +7,7 @@ -- Integration Points and depended on by the two consumer migrations), and it -- keeps the producer signatures uniform with the processor/runner ones. We -- therefore silence the otherwise-correct redundant-constraint warning here.-{-# OPTIONS_GHC -Wno-redundant-constraints #-}+{-# OPTIONS_GHC -Wno-redundant-constraints -Wno-partial-fields #-}  -- | 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@@ -81,6 +81,7 @@     JobOrdering (..),     JobTuning (..),     JobTuningConfigError (..),+    JobConsumptionConfigError (..),     mkJobTuning,     defaultJobTuning,     withOrdering,@@ -104,6 +105,8 @@     -- * Queue lifecycle     QueueKind (..),     PartitionSpec (..),+    PartitionSpecConfigError (..),+    mkPartitionSpec,     QueueProvision (..),     standardProvision,     unloggedProvision,@@ -157,7 +160,7 @@     mergeTraceHeaders,   ) import "pgmq-effectful" Pgmq.Effectful qualified as Pgmq-import "pgmq-effectful" Pgmq.Effectful.Effect (readGrouped, readGroupedRoundRobin)+import "pgmq-effectful" Pgmq.Effectful.Effect (readGrouped, readGroupedHead, readGroupedRoundRobin) import "pgmq-hasql" Pgmq.Hasql.Statements.Types (ReadGrouped (..)) import "shibuya-core" Shibuya.App   ( AppConfig (..),@@ -225,6 +228,7 @@   ) import "text" Data.Text (Text) import "text" Data.Text qualified as Text+import "text" Data.Text.Read qualified as TextRead import "time" Data.Time (NominalDiffTime, nominalDiffTimeToSeconds)  -- | What a job handler decides. Never exposes shibuya/PGMQ wire types to the caller.@@ -297,17 +301,22 @@ -- 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.+-- 'FifoHeads' enables strict per-group ordering via PGMQ message groups (the+-- reserved @x-pgmq-group@ header). It returns at most one absolute head per+-- group, so a failed or delayed head blocks its successors while distinct groups+-- stay independently eligible. Current Keiro handlers remain serial.+-- -- '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.+-- (@read_grouped_rr@). Those legacy strategies may safely use only a batch size+-- of one; larger batches can lease a successor before its predecessor settles.+-- Delivery is still at-least-once and there is no deduplication, so handlers+-- must be idempotent. data JobOrdering   = Unordered   | FifoThroughput   | FifoRoundRobin+  | FifoHeads   deriving stock (Eq, Show)  -- | How a consumer reads the queue.@@ -356,6 +365,37 @@ validPolling (LongPoll maxPollSeconds pollIntervalMs) =   maxPollSeconds > 0 && pollIntervalMs > 0 +-- | Invalid or contradictory consumer configuration. These checks run before+-- an adapter is constructed or a direct PGMQ read is issued, including when raw+-- 'JobTuning' and 'Job' constructors are used.+data JobConsumptionConfigError+  = InvalidJobTuning !JobTuningConfigError+  | JobOrderingMismatch+      { jobOrderingDeclared :: !JobOrdering,+        tuningOrderingGiven :: !JobOrdering+      }+  | UnsafeLegacyFifoBatch+      { unsafeOrdering :: !JobOrdering,+        unsafeBatchSize :: !Int32+      }+  deriving stock (Eq, Show)+  deriving anyclass (Exception)++validateJobConsumptionConfig :: Job p -> JobTuning -> Either JobConsumptionConfigError ()+validateJobConsumptionConfig job tuning = do+  case mkJobTuning tuning.visibilityTimeout tuning.batchSize tuning.polling of+    Left err -> Left (InvalidJobTuning err)+    Right _ -> Right ()+  if job.jobOrdering == tuning.ordering+    then Right ()+    else Left JobOrderingMismatch {jobOrderingDeclared = job.jobOrdering, tuningOrderingGiven = tuning.ordering}+  case tuning.ordering of+    FifoThroughput | tuning.batchSize > 1 -> unsafe+    FifoRoundRobin | tuning.batchSize > 1 -> unsafe+    _ -> Right ()+  where+    unsafe = Left UnsafeLegacyFifoBatch {unsafeOrdering = tuning.ordering, unsafeBatchSize = tuning.batchSize}+ toPollingConfig :: JobPolling -> PollingConfig toPollingConfig (PollEvery interval) = StandardPolling interval toPollingConfig (LongPoll maxPollSeconds pollIntervalMs) = LongPolling maxPollSeconds pollIntervalMs@@ -365,6 +405,7 @@ toFifoConfig Unordered = Nothing toFifoConfig FifoThroughput = Just (FifoConfig ThroughputOptimized) toFifoConfig FifoRoundRobin = Just (FifoConfig RoundRobin)+toFifoConfig FifoHeads = Just (FifoConfig HeadPerGroup)  retryDelaySeconds :: RetryDelay -> NominalDiffTime retryDelaySeconds (RetryDelay seconds) = seconds@@ -380,14 +421,16 @@       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+-- | A declarative job: a queue, a payload codec, an ordering contract, 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),+    -- | Required consumption ordering. Explicit tuning must match this value.+    jobOrdering :: !JobOrdering,     jobPolicy :: !RetryPolicy   } @@ -529,9 +572,9 @@ -- | 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.+-- ordered 'JobTuning' (see 'withOrdering') to honor the order. With 'FifoHeads',+-- messages are handled in strict send order within one group while distinct+-- groups can be claimed together; current Keiro handlers remain serial. enqueueToGroup ::   (Pgmq :> es, IOE :> es) => Job p -> Text -> p -> Eff es MessageId enqueueToGroup job groupKey p =@@ -554,25 +597,92 @@ --     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').+--     range, managed by the PostgreSQL extension @pg_partman@. This mode is+--     experimental and requires a @pg_partman@-enabled server. Retention drops+--     whole old partitions, including unprocessed active rows (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.+-- | Partition interval and retention policy for a partitioned queue. PGMQ passes+-- both values to PostgreSQL/@pg_partman@: time values are opaque strings such as+-- @"daily"@ and @"7 days"@, while positive PostgreSQL @INTEGER@ text selects+-- message-id-range partitioning.+--+-- Retention is not per-message expiry. Maintenance permanently drops whole old+-- partitions from both the active queue and its archive without checking whether+-- an active message was processed. Size retention above the worst expected+-- consumer outage and backlog age, with an operator-chosen safety margin.+--+-- The raw constructor is intentionally available for server-specific values but+-- is unvalidated. Prefer 'mkPartitionSpec' for the bounded checks Keiro can make+-- without duplicating PostgreSQL's interval parser. data PartitionSpec = PartitionSpec   { partitionInterval :: !Text,     retentionInterval :: !Text   }   deriving stock (Eq, Show) +-- | Why 'mkPartitionSpec' rejected a partition configuration.+data PartitionSpecConfigError+  = EmptyPartitionInterval+  | EmptyRetentionInterval+  | NonPositivePartitionInterval !Integer+  | NonPositiveRetentionInterval !Integer+  | PartitionIntervalOutsidePostgresInteger !Integer+  | MixedPartitionUnits !Text !Text+  | RetentionBelowPartitionInterval !Integer !Integer+  deriving stock (Eq, Show)++-- | Construct a partition specification after applying Keiro's bounded+-- preflight checks. Inputs are trimmed. Fully numeric values must use matching+-- message-id units; the partition span must fit PostgreSQL's positive signed+-- 32-bit @INTEGER@ classifier, and numeric retention must cover at least one+-- configured span. Nonnumeric pairs remain opaque for PostgreSQL/@pg_partman@ to+-- validate.+--+-- These checks reject obvious mistakes; they do not prove a safe retention+-- horizon. Operators must still account for worst-case outage and backlog age.+mkPartitionSpec :: Text -> Text -> Either PartitionSpecConfigError PartitionSpec+mkPartitionSpec rawPartition rawRetention+  | Text.null partition = Left EmptyPartitionInterval+  | Text.null retention = Left EmptyRetentionInterval+  | otherwise =+      case (completeInteger partition, completeInteger retention) of+        (Just partitionValue, Just retentionValue)+          | partitionValue <= 0 -> Left (NonPositivePartitionInterval partitionValue)+          | partitionValue > toInteger (maxBound :: Int32) ->+              Left (PartitionIntervalOutsidePostgresInteger partitionValue)+          | retentionValue <= 0 -> Left (NonPositiveRetentionInterval retentionValue)+          | retentionValue < partitionValue ->+              Left (RetentionBelowPartitionInterval partitionValue retentionValue)+          | otherwise -> Right spec+        (Just partitionValue, Nothing)+          | partitionValue <= 0 -> Left (NonPositivePartitionInterval partitionValue)+          | partitionValue > toInteger (maxBound :: Int32) ->+              Left (PartitionIntervalOutsidePostgresInteger partitionValue)+          | otherwise -> Left (MixedPartitionUnits partition retention)+        (Nothing, Just retentionValue)+          | retentionValue <= 0 -> Left (NonPositiveRetentionInterval retentionValue)+          | otherwise -> Left (MixedPartitionUnits partition retention)+        (Nothing, Nothing) -> Right spec+  where+    partition = Text.strip rawPartition+    retention = Text.strip rawRetention+    spec = PartitionSpec {partitionInterval = partition, retentionInterval = retention}++    completeInteger value =+      case TextRead.signed TextRead.decimal value of+        Right (parsed, remainder) | Text.null remainder -> Just parsed+        _ -> Nothing+ -- | 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+-- whether to create PGMQ's conventional FIFO GIN index on @headers@. Presence is+-- reported by the upstream index name; it does not prove that PostgreSQL uses the+-- index for grouped-read expressions. The DLQ (when the policy enables one) is -- always a plain standard queue with no FIFO index. data QueueProvision = QueueProvision   { provisionKind :: !QueueKind,@@ -588,12 +698,17 @@ unloggedProvision :: QueueProvision unloggedProvision = QueueProvision {provisionKind = UnloggedKind, provisionFifoIndex = False} --- | A partitioned main queue (no FIFO index) with the given interval/retention.+-- | An experimental partitioned main queue (no FIFO index) with the given+-- interval and retention. Maintenance drops whole old active and archive+-- partitions, so unprocessed work can be lost when retention is shorter than an+-- outage or backlog. Prefer a value built by 'mkPartitionSpec'. partitionedProvision :: PartitionSpec -> QueueProvision partitionedProvision spec =   QueueProvision {provisionKind = PartitionedKind spec, provisionFifoIndex = False} --- | Turn on FIFO-index creation for a provisioning choice.+-- | Request PGMQ's conventional FIFO GIN index for a provisioning choice. This+-- preserves upstream provisioning and presence-reporting behavior; it is not a+-- claim that the index accelerates every grouped-read query. withFifoIndexProvision :: QueueProvision -> QueueProvision withFifoIndexProvision provision = provision {provisionFifoIndex = True} @@ -615,7 +730,8 @@             job.jobQueue.physicalName             Config.PartitionConfig               { Config.partitionInterval = spec.partitionInterval,-                Config.retentionInterval = spec.retentionInterval+                Config.retentionInterval = spec.retentionInterval,+                Config.premake = Nothing               }     mainConfig       | provision.provisionFifoIndex = Config.withFifoIndex mainBase@@ -639,13 +755,18 @@ 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+-- | Request PGMQ's conventional FIFO GIN index on the job's /main/ queue's+-- @headers@ column. PGMQ reports presence by the conventional index name; neither+-- presence nor successful creation proves that PostgreSQL uses it for the group+-- expression in @read_grouped@, @read_grouped_rr@, or @read_grouped_head@. No+-- supplemental expression index is currently recommended without reproducible+-- workload measurements.+--+-- 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+-- is the conventional provisioning 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 ()@@ -654,9 +775,10 @@     [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.+-- the policy uses one) plus PGMQ's conventional FIFO GIN index. This preserves+-- upstream's ordered-queue setup but does not claim grouped-read acceleration.+-- 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@@ -731,6 +853,7 @@   (JobContext es -> p -> Eff es JobOutcome) ->   Eff es (ProcessorId, QueueProcessor es) jobProcessorWithContext tuning job handle = do+  either (liftIO . throwIO) pure (validateJobConsumptionConfig job tuning)   env <- ask   adapter <-     pgmqAdapter env (adapterConfigFor tuning job)@@ -752,7 +875,7 @@   (p -> Eff es JobOutcome) ->   Eff es (ProcessorId, QueueProcessor es) jobProcessor job handle =-  jobProcessorWithContext defaultJobTuning job (\_context p -> handle p)+  jobProcessorWithContext (withOrdering job.jobOrdering 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@@ -865,9 +988,9 @@   Job p ->   (JobContext es -> p -> Eff es JobOutcome) ->   Eff es Int-runJobOnceWithContext tuning n job handle-  | n <= 0 = pure 0-  | otherwise = drain 0+runJobOnceWithContext tuning n job handle = do+  either (liftIO . throwIO) pure (validateJobConsumptionConfig job tuning)+  if n <= 0 then pure 0 else drain 0   where     drain handled       | handled >= n = pure handled@@ -896,6 +1019,13 @@                     visibilityTimeout = tuning.visibilityTimeout,                     qty = qty                   }+            FifoHeads ->+              readGroupedHead+                ReadGrouped+                  { queueName = job.jobQueue.physicalName,+                    visibilityTimeout = tuning.visibilityTimeout,+                    qty = qty+                  }           if null messages             then pure handled             else do@@ -1042,7 +1172,7 @@ runJobOnce n job handle =   void $     runJobOnceWithContext-      defaultJobTuning+      (withOrdering job.jobOrdering defaultJobTuning)       n       job       (\_context p -> handle p)
src/Keiro/PGMQ/Metrics.hs view
@@ -7,8 +7,9 @@ -- 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.+-- 'Keiro.PGMQ.Dlq' recommends; after inspection, pair it with+-- 'Keiro.PGMQ.Dlq.archiveDlqEntries' and inspect the typed+-- 'Keiro.PGMQ.Dlq.PurgeDlqResult' returned by 'Keiro.PGMQ.Dlq.purgeDlq'. module Keiro.PGMQ.Metrics   ( QueueMetrics (..),     jobQueueMetrics,
test/Main.hs view
@@ -4,6 +4,7 @@ {-# LANGUAGE DerivingStrategies #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE OverloadedRecordDot #-}+{-# LANGUAGE TypeApplications #-}  -- | End-to-end integration test for @keiro-pgmq@. --@@ -17,7 +18,7 @@ module Main (main) where  import Control.Concurrent (threadDelay)-import Control.Exception (bracket, throwIO)+import Control.Exception (bracket, throwIO, try) import Data.Aeson (FromJSON, ToJSON, Value (..), object, parseJSON, toJSON, (.=)) import Data.Aeson.Key qualified as Key import Data.Aeson.KeyMap qualified as KeyMap@@ -57,7 +58,7 @@ 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 (Message (..), MessageBody (..), Pgmq, ReadMessage (..), SendMessage (..)) import Pgmq.Effectful qualified as Pgmq import Pgmq.Migration qualified as Migration import Pgmq.Types (QueueName, parseQueueName, queueNameToText)@@ -130,6 +131,7 @@     { jobName = name,       jobQueue = queueRef name,       jobCodec = aesonJobCodec,+      jobOrdering = Unordered,       jobPolicy = defaultRetryPolicy     } @@ -376,6 +378,33 @@     mkJobTuning 30 1 (PollEvery 1)       `shouldBe` Right defaultJobTuning +  it "validates partition specifications without parsing time intervals" $ \_connStr -> do+    mkPartitionSpec " daily " " 7 days "+      `shouldBe` Right (PartitionSpec "daily" "7 days")+    mkPartitionSpec " 10000 " " 100000 "+      `shouldBe` Right (PartitionSpec "10000" "100000")+    mkPartitionSpec "" "7 days"+      `shouldBe` Left EmptyPartitionInterval+    mkPartitionSpec "daily" "   "+      `shouldBe` Left EmptyRetentionInterval+    mkPartitionSpec "0" "100"+      `shouldBe` Left (NonPositivePartitionInterval 0)+    mkPartitionSpec "-1" "100"+      `shouldBe` Left (NonPositivePartitionInterval (-1))+    mkPartitionSpec "100" "0"+      `shouldBe` Left (NonPositiveRetentionInterval 0)+    mkPartitionSpec "100" "-1"+      `shouldBe` Left (NonPositiveRetentionInterval (-1))+    mkPartitionSpec "2147483648" "2147483648"+      `shouldBe` Left (PartitionIntervalOutsidePostgresInteger 2147483648)+    mkPartitionSpec "100" "7 days"+      `shouldBe` Left (MixedPartitionUnits "100" "7 days")+    mkPartitionSpec "daily" "100"+      `shouldBe` Left (MixedPartitionUnits "daily" "100")+    mkPartitionSpec "100" "99"+      `shouldBe` Left (RetentionBelowPartitionInterval 100 99)+    PartitionSpec "" "" `shouldBe` PartitionSpec "" ""+   it "derives distinct physical names for long logical queue names" $ \_connStr -> do     let commonPrefix = Text.replicate 43 "a"         first = queueRef (commonPrefix <> "x")@@ -688,10 +717,11 @@   it "readDlq decodes the original dead-lettered payload" $ \connStr -> do     let job = mkJob "keiro_pgmq_test.dlq_read"         payload = Ping "poison" 3+        headers = object ["tenant" .= ("acme" :: Text)]     entries <-       runDb connStr $ do         ensureJobQueue job-        _ <- enqueue job payload+        _ <- enqueueWithHeaders job (MessageHeaders headers) payload         runJobOnce 1 job (\_ -> pure (Dead "bad"))         readDlq job 1     case entries of@@ -700,6 +730,7 @@         entry.originalPayload `shouldBe` Right payload         entry.originalMessageId `shouldSatisfy` (/= Nothing)         entry.readCount `shouldBe` Just 1+        entry.originalHeaders `shouldBe` Just headers       _ -> expectationFailure ("expected one DLQ entry, got " <> show (length entries))    it "redriveDlq moves dead-lettered payloads back to the main queue" $ \connStr -> do@@ -720,16 +751,127 @@     finalMainLen <- runDb connStr (queueLen job.jobQueue.physicalName)     finalMainLen `shouldBe` 0 -  it "purgeDlq empties the DLQ" $ \connStr -> do+  it "redriveDlq preserves the wrapper's original headers" $ \connStr -> do+    let job = mkJob "keiro_pgmq_test.dlq_redrive_headers"+        originalHeaders =+          object+            [ "x-pgmq-group" .= ("orders" :: Text),+              "traceparent" .= ("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" :: Text),+              "tenant" .= ("acme" :: Text)+            ]+    messages <-+      runDb connStr $ do+        ensureJobQueue job+        _ <- enqueueWithHeaders job (MessageHeaders originalHeaders) (Ping "redrive-headers" 1)+        runJobOnce 1 job (\_ -> pure (Dead "bad"))+        redriven <- redriveDlq job 1+        liftIO (redriven `shouldBe` 1)+        readMessages job.jobQueue.physicalName 1+    case messages of+      [message] -> message.headers `shouldBe` Just originalHeaders+      _ -> expectationFailure ("expected one redriven message, got " <> show (length messages))++  it "redriveDlq uses wrapper headers instead of the DLQ row's headers" $ \connStr -> do+    let job = mkJob "keiro_pgmq_test.dlq_redrive_wrapper_headers"+        originalHeaders = object ["tenant" .= ("original" :: Text)]+        dlqRowHeaders = object ["tenant" .= ("dlq-row" :: Text)]+        wrapper =+          object+            [ "original_message" .= Ping "wrapper-headers" 1,+              "dead_letter_reason" .= ("poison_pill: bad" :: Text),+              "original_headers" .= originalHeaders+            ]+    messages <-+      runDb connStr $ do+        ensureJobQueue job+        _ <-+          Pgmq.sendMessageWithHeaders+            Pgmq.SendMessageWithHeaders+              { queueName = job.jobQueue.dlqName,+                messageBody = MessageBody wrapper,+                messageHeaders = MessageHeaders dlqRowHeaders,+                delay = Nothing+              }+        _ <- redriveDlq job 1+        readMessages job.jobQueue.physicalName 1+    case messages of+      [message] -> message.headers `shouldBe` Just originalHeaders+      _ -> expectationFailure ("expected one redriven message, got " <> show (length messages))++  it "redriveDlq keeps missing and null wrapper headers headerless" $ \connStr -> do+    let rowHeaders = MessageHeaders (object ["tenant" .= ("dlq-row" :: Text)])+        redriveLegacy queue wrapper = do+          let job = mkJob queue+          runDb connStr $ do+            ensureJobQueue job+            _ <-+              Pgmq.sendMessageWithHeaders+                Pgmq.SendMessageWithHeaders+                  { queueName = job.jobQueue.dlqName,+                    messageBody = MessageBody wrapper,+                    messageHeaders = rowHeaders,+                    delay = Nothing+                  }+            _ <- redriveDlq job 1+            readMessages job.jobQueue.physicalName 1+        missingHeaders =+          object+            [ "original_message" .= Ping "missing-headers" 1,+              "dead_letter_reason" .= ("poison_pill: bad" :: Text)+            ]+        nullHeaders =+          object+            [ "original_message" .= Ping "null-headers" 1,+              "dead_letter_reason" .= ("poison_pill: bad" :: Text),+              "original_headers" .= Null+            ]+    missingMessages <- redriveLegacy "keiro_pgmq_test.dlq_redrive_missing_headers" missingHeaders+    nullMessages <- redriveLegacy "keiro_pgmq_test.dlq_redrive_null_headers" nullHeaders+    map (.headers) missingMessages `shouldBe` [Nothing]+    map (.headers) nullMessages `shouldBe` [Nothing]++  it "purgeDlq empties a fully visible DLQ and reports the deleted count" $ \connStr -> do     let job = mkJob "keiro_pgmq_test.dlq_purge"-    runDb connStr $ do+    result <- runDb connStr $ do       ensureJobQueue job       _ <- enqueue job (Ping "purge" 1)       runJobOnce 1 job (\_ -> pure (Dead "bad"))       purgeDlq job+    result `shouldBe` PurgeDlqPurged 1     dlqLen <- runDb connStr (queueLen job.jobQueue.dlqName)     dlqLen `shouldBe` 0 +  it "purgeDlq refuses when inspection has hidden a row and deletes nothing" $ \connStr -> do+    let job = mkJob "keiro_pgmq_test.dlq_purge_blocked"+    (result, remaining) <-+      runDb connStr $ do+        ensureJobQueue job+        _ <- enqueue job (Ping "hidden" 1)+        _ <- enqueue job (Ping "visible" 2)+        runJobOnce 2 job (\_ -> pure (Dead "bad"))+        inspected <- readDlq job 1+        liftIO (length inspected `shouldBe` 1)+        result <- purgeDlq job+        remaining <- queueLen job.jobQueue.dlqName+        pure (result, remaining)+    result `shouldBe` PurgeDlqBlocked 1+    remaining `shouldBe` 2++  it "purgeDlqForce deletes hidden rows" $ \connStr -> do+    let job = mkJob "keiro_pgmq_test.dlq_purge_force"+    (purged, remaining) <-+      runDb connStr $ do+        ensureJobQueue job+        _ <- enqueue job (Ping "hidden" 1)+        runJobOnce 1 job (\_ -> pure (Dead "bad"))+        inspected <- readDlq job 1+        liftIO (length inspected `shouldBe` 1)+        purged <- purgeDlqForce job+        remaining <- queueLen job.jobQueue.dlqName+        pure (purged, remaining)+    purged `shouldBe` 1+    remaining `shouldBe` 0+   it "readDlq preserves malformed DLQ wrappers as malformed entries" $ \connStr -> do     let job = mkJob "keiro_pgmq_test.dlq_malformed"     entries <-@@ -749,6 +891,7 @@         entry.originalPayload `shouldSatisfy` \case           Left (JobPayloadMalformed _) -> True           _ -> False+        entry.originalHeaders `shouldBe` Nothing       _ -> expectationFailure ("expected one malformed DLQ entry, got " <> show (length entries))    it "undecodable payload routes to the DLQ" $ \connStr -> do@@ -1033,7 +1176,7 @@   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"+        job = (mkJob "keiro_pgmq_test.span_partition") {jobOrdering = FifoThroughput}     drained <-       runDbTraced connStr tracer $ do         ensureOrderedJobQueue job@@ -1066,13 +1209,14 @@   -- 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+        partitionSpec = either (error . show) id (mkPartitionSpec "daily" "7 days")+    case queueProvisionConfigs (partitionedProvision partitionSpec) job of       (mainCfg : _) ->         case mainCfg.queueType of           Config.PartitionedQueue pc -> do             pc.partitionInterval `shouldBe` "daily"             pc.retentionInterval `shouldBe` "7 days"+            pc.premake `shouldBe` Nothing             mainCfg.queueName `shouldBe` job.jobQueue.physicalName           other ->             expectationFailure@@ -1110,7 +1254,7 @@       _ -> 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"+    let job = (mkJob "keiro_pgmq_test.ordered_setup") {jobOrdering = FifoThroughput}     len <-       runDb connStr $ do         ensureOrderedJobQueue job@@ -1122,10 +1266,263 @@         queueLen job.jobQueue.physicalName     len `shouldBe` 0 +  it "rejects invalid, mismatched, and unsafe raw consumption tuning before a read" $ \connStr -> do+    let unorderedJob = mkJob "keiro_pgmq_test.invalid_consumption"+        headsJob = unorderedJob {jobOrdering = FifoHeads}+        throughputJob = unorderedJob {jobOrdering = FifoThroughput}+        invalidTuning =+          JobTuning+            { visibilityTimeout = 30,+              batchSize = 0,+              polling = PollEvery 1,+              ordering = Unordered+            }+        mismatchedTuning = defaultJobTuning+        unsafeTuning =+          withOrdering FifoThroughput $+            either (error . show) id $+              mkJobTuning 30 8 (PollEvery 1)+        run tuning job =+          runDb connStr $+            runJobOnceWithContext tuning 0 job \_ctx _payload -> pure Done+    invalid <- try @JobConsumptionConfigError (run invalidTuning unorderedJob)+    mismatch <- try @JobConsumptionConfigError (run mismatchedTuning headsJob)+    unsafeBatch <- try @JobConsumptionConfigError (run unsafeTuning throughputJob)+    invalid `shouldBe` Left (InvalidJobTuning (NonPositiveBatchSize 0))+    mismatch+      `shouldBe` Left+        JobOrderingMismatch+          { jobOrderingDeclared = FifoHeads,+            tuningOrderingGiven = Unordered+          }+    unsafeBatch+      `shouldBe` Left+        UnsafeLegacyFifoBatch+          { unsafeOrdering = FifoThroughput,+            unsafeBatchSize = 8+          }++  it "accepts large batches for unordered and grouped-head jobs" $ \connStr -> do+    let unorderedJob = mkJob "keiro_pgmq_test.valid_unordered_batch"+        headsJob = (mkJob "keiro_pgmq_test.valid_heads_batch") {jobOrdering = FifoHeads}+        unorderedTuning = either (error . show) id $ mkJobTuning 30 50 (PollEvery 1)+        headsTuning = withOrdering FifoHeads unorderedTuning+        run tuning job =+          runDb connStr $+            runJobOnceWithContext tuning 0 job \_ctx _payload -> pure Done+    run unorderedTuning unorderedJob `shouldReturn` 0+    run headsTuning headsJob `shouldReturn` 0+    headsTuning.batchSize `shouldBe` 50++  it "rejects mismatched ordering before emitting a receive span" $ \connStr -> do+    (provider, spansRef) <- setupCapturingProvider+    let tracer = OTel.makeTracer provider "keiro-pgmq-test" OTel.tracerOptions+        job = (mkJob "keiro_pgmq_test.mismatch_no_receive") {jobOrdering = FifoHeads}+        receiveName = "receive " <> queueNameToText job.jobQueue.physicalName+    result <-+      try @JobConsumptionConfigError $+        runDbTraced connStr tracer $+          runJobOnceWithContext defaultJobTuning 1 job \_ctx _payload -> pure Done+    case result of+      Left JobOrderingMismatch {} -> pure ()+      Left err -> expectationFailure ("unexpected mismatch error: " <> show err)+      Right _ -> expectationFailure "expected mismatched ordering to fail"+    spans <- capturedSpans provider spansRef+    map csName spans `shouldNotSatisfy` elem receiveName++  it "worker construction rejects invalid raw tuning and mismatched ordering before adapter use" $ \connStr -> do+    let job = (mkJob "keiro_pgmq_test.worker_mismatch") {jobOrdering = FifoHeads}+        invalidTuning =+          JobTuning+            { visibilityTimeout = 0,+              batchSize = 1,+              polling = PollEvery 1,+              ordering = FifoHeads+            }+        build tuning =+          runDb connStr $+            jobProcessorWithContext tuning job \_ctx _payload -> pure Done+    invalid <- try @JobConsumptionConfigError (build invalidTuning)+    mismatch <-+      try @JobConsumptionConfigError $+        build defaultJobTuning+    case invalid of+      Left (InvalidJobTuning (NonPositiveVisibilityTimeout 0)) -> pure ()+      Left err -> expectationFailure ("unexpected invalid-tuning error: " <> show err)+      Right _ -> expectationFailure "expected worker construction to reject raw invalid tuning"+    case mismatch of+      Left JobOrderingMismatch {jobOrderingDeclared = FifoHeads, tuningOrderingGiven = Unordered} -> pure ()+      Left err -> expectationFailure ("unexpected validation error: " <> show err)+      Right _ -> expectationFailure "expected worker construction to reject mismatched ordering"++  it "FifoHeads blocks a failed group head while other groups continue" $ \connStr -> do+    firstA <- newIORef True+    observed <- newIORef ([] :: [Text])+    let job = (mkJob "keiro_pgmq_test.fifo_heads_failure") {jobOrdering = FifoHeads}+        tuning =+          withOrdering FifoHeads $+            either (error . show) id $+              mkJobTuning 1 50 (PollEvery 0.1)+        handler _ctx payload = do+          liftIO $ modifyIORef' observed (<> [payload.message])+          shouldFail <- liftIO $ do+            firstPending <- readIORef firstA+            if payload.message == "a1" && firstPending+              then writeIORef firstA False >> pure True+              else pure False+          if shouldFail+            then liftIO (throwIO (userError "fail a1 once"))+            else pure Done+    (firstDrain, independentDrain) <-+      runDb connStr $ do+        ensureOrderedJobQueue job+        _ <- enqueueToGroup job "a" (Ping "a1" 1)+        _ <- enqueueToGroup job "a" (Ping "a2" 2)+        _ <- enqueueToGroup job "b" (Ping "b1" 1)+        _ <- enqueueToGroup job "b" (Ping "b2" 2)+        firstDrain <- runJobOnceWithContext tuning 2 job handler+        independentDrain <- runJobOnceWithContext tuning 2 job handler+        pure (firstDrain, independentDrain)+    firstDrain `shouldBe` 2+    independentDrain `shouldBe` 0+    readIORef observed `shouldReturn` ["a1", "b1", "b2"]+    threadDelay 1_100_000+    recovered <- runDb connStr $ runJobOnceWithContext tuning 2 job handler+    recovered `shouldBe` 2+    readIORef observed `shouldReturn` ["a1", "b1", "b2", "a1", "a2"]++  it "FifoHeads blocks a group successor when its head returns Retry" $ \connStr -> do+    observed <- newIORef ([] :: [Text])+    let job = (mkJob "keiro_pgmq_test.fifo_heads_retry") {jobOrdering = FifoHeads}+        tuning =+          withOrdering FifoHeads $+            either (error . show) id $+              mkJobTuning 30 50 (PollEvery 0.1)+        handler _ctx payload = do+          liftIO $ modifyIORef' observed (<> [payload.message])+          pure $+            if payload.message == "a1"+              then Retry (RetryDelay 30)+              else Done+    (firstDrain, blockedDrain) <-+      runDb connStr $ do+        ensureOrderedJobQueue job+        _ <- enqueueToGroup job "a" (Ping "a1" 1)+        _ <- enqueueToGroup job "a" (Ping "a2" 2)+        _ <- enqueueToGroup job "b" (Ping "b1" 1)+        firstDrain <- runJobOnceWithContext tuning 2 job handler+        blockedDrain <- runJobOnceWithContext tuning 1 job handler+        pure (firstDrain, blockedDrain)+    firstDrain `shouldBe` 2+    blockedDrain `shouldBe` 0+    readIORef observed `shouldReturn` ["a1", "b1"]++  it "FifoHeads drains sixteen independent heads with one receive" $ \connStr -> do+    (provider, spansRef) <- setupCapturingProvider+    let tracer = OTel.makeTracer provider "keiro-pgmq-test" OTel.tracerOptions+        job = (mkJob "keiro_pgmq_test.fifo_heads_batch") {jobOrdering = FifoHeads}+        tuning =+          withOrdering FifoHeads $+            either (error . show) id $+              mkJobTuning 30 16 (PollEvery 0.1)+        messages = [("g" <> Text.pack (show i), Ping ("m" <> Text.pack (show i)) i) | i <- [1 .. 16]]+        receiveName = "receive " <> queueNameToText job.jobQueue.physicalName+    drained <-+      runDbTraced connStr tracer $ do+        ensureOrderedJobQueue job+        traverse_ (\(groupName, payload) -> enqueueToGroup job groupName payload) messages+        runJobOnceWithContext tuning 16 job \_ctx _payload -> pure Done+    drained `shouldBe` 16+    spans <- capturedSpans provider spansRef+    length (filter ((== receiveName) . csName) spans) `shouldBe` 1++  it "FifoHeads blocks a successor behind a delayed head while other groups run" $ \connStr -> do+    observed <- newIORef ([] :: [Text])+    let job = (mkJob "keiro_pgmq_test.fifo_heads_delay") {jobOrdering = FifoHeads}+        tuning =+          withOrdering FifoHeads $+            either (error . show) id $+              mkJobTuning 30 50 (PollEvery 1)+        handler _ctx payload = do+          liftIO $ modifyIORef' observed (<> [payload.message])+          pure Done+    independentDrain <-+      runDb connStr $ do+        ensureOrderedJobQueue job+        _ <- enqueueToGroup job "a" (Ping "a1" 1)+        _ <- runJobOnceWithContext tuning 1 job handler+        _ <- enqueueToGroupWithDelay job 2 "a" (Ping "a2" 2)+        _ <- enqueueToGroup job "a" (Ping "a3" 3)+        _ <- enqueueToGroup job "b" (Ping "b1" 1)+        runJobOnceWithContext tuning 3 job handler+    independentDrain `shouldBe` 1+    readIORef observed `shouldReturn` ["a1", "b1"]+    threadDelay 2_100_000+    recovered <- runDb connStr $ runJobOnceWithContext tuning 2 job handler+    recovered `shouldBe` 2+    readIORef observed `shouldReturn` ["a1", "b1", "a2", "a3"]++  it "FifoHeads advances a group after its head is dead-lettered" $ \connStr -> do+    observed <- newIORef ([] :: [Text])+    let job = (mkJob "keiro_pgmq_test.fifo_heads_dead") {jobOrdering = FifoHeads}+        tuning = withOrdering FifoHeads defaultJobTuning+        record outcome _ctx payload = do+          liftIO $ modifyIORef' observed (<> [payload.message])+          pure outcome+    (deadCount, successorCount, mainDepth, dlqDepth) <-+      runDb connStr $ do+        ensureOrderedJobQueue job+        _ <- enqueueToGroup job "a" (Ping "a1" 1)+        _ <- enqueueToGroup job "a" (Ping "a2" 2)+        deadCount <- runJobOnceWithContext tuning 1 job (record (Dead "poison"))+        successorCount <- runJobOnceWithContext tuning 1 job (record Done)+        mainDepth <- queueLen job.jobQueue.physicalName+        dlqDepth <- queueLen job.jobQueue.dlqName+        pure (deadCount, successorCount, mainDepth, dlqDepth)+    deadCount `shouldBe` 1+    successorCount `shouldBe` 1+    mainDepth `shouldBe` 0+    dlqDepth `shouldBe` 1+    readIORef observed `shouldReturn` ["a1", "a2"]++  it "default one-shot tuning inherits the job's grouped-head ordering" $ \connStr -> do+    let job = (mkJob "keiro_pgmq_test.default_heads") {jobOrdering = FifoHeads}+    len <-+      runDb connStr $ do+        ensureOrderedJobQueue job+        _ <- enqueueToGroup job "a" (Ping "a1" 1)+        runJobOnce 1 job (\_payload -> pure Done)+        queueLen job.jobQueue.physicalName+    len `shouldBe` 0++  it "default worker tuning inherits the job's grouped-head ordering" $ \connStr -> do+    let job = (mkJob "keiro_pgmq_test.default_heads_worker") {jobOrdering = FifoHeads}+    result <-+      try @JobConsumptionConfigError $+        runDb connStr $+          jobProcessor job (\_payload -> pure Done)+    case result of+      Left err -> expectationFailure ("default worker tuning was rejected: " <> show err)+      Right _ -> pure ()++  it "legacy FIFO strategies remain available with batch size one" $ \connStr -> do+    traverse_+      ( \(suffix, declaredOrdering) -> do+          let job = (mkJob ("keiro_pgmq_test.legacy_fifo_" <> suffix)) {jobOrdering = declaredOrdering}+              tuning = withOrdering declaredOrdering defaultJobTuning+          drained <-+            runDb connStr $ do+              ensureOrderedJobQueue job+              _ <- enqueueToGroup job "a" (Ping "a1" 1)+              runJobOnceWithContext tuning 1 job \_ctx _payload -> pure Done+          drained `shouldBe` 1+      )+      [("throughput", FifoThroughput), ("round_robin", FifoRoundRobin)]+   -- 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"+    let job = (mkJob "keiro_pgmq_test.fifo_order") {jobOrdering = FifoThroughput}     drained <-       runDb connStr $ do         ensureOrderedJobQueue job@@ -1144,13 +1541,13 @@     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+  it "FifoHeads worker path preserves within-group order with a large batch" $ \connStr -> do     observed <- newIORef ([] :: [Text])-    let job = mkJob "keiro_pgmq_test.fifo_worker"+    let job = (mkJob "keiro_pgmq_test.fifo_worker") {jobOrdering = FifoHeads}         tuning =-          withOrdering FifoThroughput $+          withOrdering FifoHeads $             either (error . show) id $-              mkJobTuning 30 1 (PollEvery 0.1)+              mkJobTuning 30 50 (PollEvery 0.1)     processed <-       runDb connStr $ do         ensureOrderedJobQueue job@@ -1177,6 +1574,49 @@     len <- runDb connStr (queueLen job.jobQueue.physicalName)     len `shouldBe` 0 +  it "FifoHeads worker retries a failed head before its successor while other groups progress" $ \connStr -> do+    firstA <- newIORef True+    observed <- newIORef ([] :: [Text])+    let job = (mkJob "keiro_pgmq_test.fifo_worker_retry") {jobOrdering = FifoHeads}+        tuning =+          withOrdering FifoHeads $+            either (error . show) id $+              mkJobTuning 30 50 (PollEvery 0.1)+        handler _ctx payload = do+          liftIO $ modifyIORef' observed (<> [payload.message])+          retryA <- liftIO $ do+            retryPending <- readIORef firstA+            if payload.message == "a1" && retryPending+              then writeIORef firstA False >> pure True+              else pure False+          pure $+            if retryA+              then Retry (RetryDelay 0)+              else Done+    processed <-+      runDb connStr $ do+        ensureOrderedJobQueue job+        _ <- enqueueToGroup job "a" (Ping "a1" 1)+        _ <- enqueueToGroup job "a" (Ping "a2" 2)+        _ <- enqueueToGroup job "b" (Ping "b1" 1)+        result <- runJobWorkers IgnoreFailures 16 [jobProcessorWithContext tuning job handler]+        case result of+          Left err -> liftIO $ fail ("runJobWorkers failed: " <> show err)+          Right app -> do+            ok <- liftIO $ waitUntil do+              messages <- readIORef observed+              pure $+                filter (Text.isPrefixOf "a") messages == ["a1", "a1", "a2"]+                  && filter (Text.isPrefixOf "b") messages == ["b1"]+            stopAppQuickly app+            pure ok+    processed `shouldBe` True+    log' <- readIORef observed+    filter (Text.isPrefixOf "a") log' `shouldBe` ["a1", "a1", "a2"]+    filter (Text.isPrefixOf "b") log' `shouldBe` ["b1"]+    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"@@ -1191,6 +1631,7 @@         pure (mainMetrics, dlqMetrics)     mainMetrics.queueLength `shouldBe` 3     mainMetrics.queueVisibleLength `shouldBe` 3+    mainMetrics.defaultPartitionLength `shouldBe` Nothing     dlqMetrics.queueLength `shouldBe` 0    it "jobDlqMetrics reports DLQ depth after a Dead outcome" $ \connStr -> do@@ -1222,18 +1663,68 @@     retained <- archiveCount connStr (queueNameToText job.jobQueue.dlqName)     retained `shouldBe` 1 +  it "archiveDlqEntries archives inspected rows immediately and is idempotent" $ \connStr -> do+    let job = mkJob "keiro_pgmq_test.dlq_archive_entries"+        unknownId = Pgmq.MessageId 999999+    (entryIds, countArchived, moved, repeated, unknown, remaining) <-+      runDb connStr $ do+        ensureJobQueue job+        _ <- enqueue job (Ping "first" 1)+        _ <- enqueue job (Ping "second" 2)+        runJobOnce 2 job (\_ -> pure (Dead "bad"))+        entries <- readDlq job 2+        let entryIds = map (.dlqMessageId) entries+        countArchived <- archiveDlq job 10+        empty <- archiveDlqEntries job []+        liftIO (empty `shouldBe` [])+        moved <- archiveDlqEntries job entryIds+        repeated <- archiveDlqEntries job entryIds+        unknown <- archiveDlqEntries job [unknownId]+        remaining <- queueLen job.jobQueue.dlqName+        pure (entryIds, countArchived, moved, repeated, unknown, remaining)+    length entryIds `shouldBe` 2+    countArchived `shouldBe` 0+    moved `shouldMatchList` entryIds+    repeated `shouldBe` []+    unknown `shouldBe` []+    remaining `shouldBe` 0+    retained <- archiveCount connStr (queueNameToText job.jobQueue.dlqName)+    retained `shouldBe` 2++  it "inspect archive-by-ids and guarded purge completes without waiting" $ \connStr -> do+    let job = mkJob "keiro_pgmq_test.dlq_no_wait_runbook"+    (inspectedCount, inspectedIds, archivedIds, purgeResult, remaining) <-+      runDb connStr $ do+        ensureJobQueue job+        _ <- enqueue job (Ping "first" 1)+        _ <- enqueue job (Ping "second" 2)+        runJobOnce 2 job (\_ -> pure (Dead "bad"))+        entries <- readDlq job 2+        let inspectedIds = map (.dlqMessageId) entries+        archivedIds <- archiveDlqEntries job inspectedIds+        purgeResult <- purgeDlq job+        remaining <- queueLen job.jobQueue.dlqName+        pure (length entries, inspectedIds, archivedIds, purgeResult, remaining)+    inspectedCount `shouldBe` 2+    archivedIds `shouldMatchList` inspectedIds+    purgeResult `shouldBe` PurgeDlqPurged 0+    remaining `shouldBe` 0+    retained <- archiveCount connStr (queueNameToText job.jobQueue.dlqName)+    retained `shouldBe` 2+   -- 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 <-+    (archived, purged) <-       runDb connStr $ do         ensureJobQueue job         _ <- enqueue job (Ping "poison" 1)         runJobOnce 1 job (\_ -> pure (Dead "bad"))         archived <- archiveDlq job 10-        purgeDlq job-        pure archived+        purged <- purgeDlq job+        pure (archived, purged)     archived `shouldBe` 1+    purged `shouldBe` PurgeDlqPurged 0     dlqLen <- runDb connStr (queueLen job.jobQueue.dlqName)     dlqLen `shouldBe` 0     retained <- archiveCount connStr (queueNameToText job.jobQueue.dlqName)