packages feed

shibuya-pgmq-adapter 0.16.1.0 → 0.18.0.0

raw patch · 12 files changed

+1177/−24 lines, 12 filesdep +directorydep +hs-opentelemetry-apidep +hs-opentelemetry-exporter-in-memorydep ~ephemeral-pgdep ~pgmq-coredep ~pgmq-effectfulPVP ok

version bump matches the API change (PVP)

Dependencies added: directory, hs-opentelemetry-api, hs-opentelemetry-exporter-in-memory, unix

Dependency ranges changed: ephemeral-pg, pgmq-core, pgmq-effectful, pgmq-hasql, pgmq-migration, shibuya-core

API changes (from Hackage documentation)

+ Shibuya.Adapter.Pgmq.Convert: mkDlqPayloadForQueue :: QueueName -> Message -> DeadLetterReason -> Bool -> MessageBody
+ Shibuya.Adapter.Pgmq.DeadLetter: AlreadyAbsent :: RedriveOutcome
+ Shibuya.Adapter.Pgmq.DeadLetter: ConfiguredFallback :: SourceResolution
+ Shibuya.Adapter.Pgmq.DeadLetter: CursorQueueMismatch :: !QueueName -> !QueueName -> DlqPageError
+ Shibuya.Adapter.Pgmq.DeadLetter: DatabaseError :: !PgmqRuntimeError -> DlqError
+ Shibuya.Adapter.Pgmq.DeadLetter: DeadLetterAdminConfig :: ![DlqRegistration] -> !Bool -> !Int32 -> !Int32 -> DeadLetterAdminConfig
+ Shibuya.Adapter.Pgmq.DeadLetter: DeadLetterEntry :: !Value -> !Text -> !Value -> !ReasonRepresentation -> !Maybe QueueName -> !SourceResolution -> !Maybe MessageId -> !Maybe Int64 -> !Maybe UTCTime -> !Maybe UTCTime -> !Maybe Value -> !MessageId -> !Int64 -> !UTCTime -> !Maybe UTCTime -> DeadLetterEntry
+ Shibuya.Adapter.Pgmq.DeadLetter: DeadLetterItem :: !QueueName -> !Message -> !Either DlqDecodeError DeadLetterEntry -> DeadLetterItem
+ Shibuya.Adapter.Pgmq.DeadLetter: DeadLetterPage :: !Vector DeadLetterItem -> !Maybe DlqCursor -> DeadLetterPage
+ Shibuya.Adapter.Pgmq.DeadLetter: DisallowedSource :: !QueueName -> DlqError
+ Shibuya.Adapter.Pgmq.DeadLetter: DlqCursor :: !QueueName -> !MessageId -> DlqCursor
+ Shibuya.Adapter.Pgmq.DeadLetter: DlqDecodeError :: !Text -> !DlqDecodeProblem -> DlqDecodeError
+ Shibuya.Adapter.Pgmq.DeadLetter: DlqPageRequest :: !Int32 -> !Maybe DlqCursor -> DlqPageRequest
+ Shibuya.Adapter.Pgmq.DeadLetter: DlqRegistration :: !QueueName -> ![QueueName] -> DlqRegistration
+ Shibuya.Adapter.Pgmq.DeadLetter: DuplicateManagedQueue :: !QueueName -> DlqConfigError
+ Shibuya.Adapter.Pgmq.DeadLetter: DuplicateSourceQueues :: !QueueName -> DlqConfigError
+ Shibuya.Adapter.Pgmq.DeadLetter: EmptySourceQueues :: !QueueName -> DlqConfigError
+ Shibuya.Adapter.Pgmq.DeadLetter: EntryNotFound :: !QueueName -> !MessageId -> DlqError
+ Shibuya.Adapter.Pgmq.DeadLetter: InvalidLockTimeout :: !Int32 -> DlqConfigError
+ Shibuya.Adapter.Pgmq.DeadLetter: InvalidPageLimit :: !Int32 -> DlqPageError
+ Shibuya.Adapter.Pgmq.DeadLetter: InvalidPageRequest :: !DlqPageError -> DlqError
+ Shibuya.Adapter.Pgmq.DeadLetter: InvalidSourceName :: !Text -> DlqDecodeProblem
+ Shibuya.Adapter.Pgmq.DeadLetter: InvalidStatementTimeout :: !Int32 -> DlqConfigError
+ Shibuya.Adapter.Pgmq.DeadLetter: LegacyReason :: ReasonRepresentation
+ Shibuya.Adapter.Pgmq.DeadLetter: MalformedEnvelope :: !DlqDecodeError -> DlqError
+ Shibuya.Adapter.Pgmq.DeadLetter: MissingField :: DlqDecodeProblem
+ Shibuya.Adapter.Pgmq.DeadLetter: NoDirectDeadLetterTarget :: DlqConfigError
+ Shibuya.Adapter.Pgmq.DeadLetter: PayloadSource :: SourceResolution
+ Shibuya.Adapter.Pgmq.DeadLetter: RedriveDisabled :: DlqError
+ Shibuya.Adapter.Pgmq.DeadLetter: Redriven :: !MessageId -> RedriveOutcome
+ Shibuya.Adapter.Pgmq.DeadLetter: SelfMapping :: !QueueName -> DlqConfigError
+ Shibuya.Adapter.Pgmq.DeadLetter: SourceNotAllowed :: !QueueName -> DlqDecodeProblem
+ Shibuya.Adapter.Pgmq.DeadLetter: SourceUnavailable :: SourceResolution
+ Shibuya.Adapter.Pgmq.DeadLetter: StructuredReason :: ReasonRepresentation
+ Shibuya.Adapter.Pgmq.DeadLetter: UnknownManagedDlq :: !QueueName -> DlqError
+ Shibuya.Adapter.Pgmq.DeadLetter: UnresolvedSource :: DlqError
+ Shibuya.Adapter.Pgmq.DeadLetter: WrongType :: !Text -> DlqDecodeProblem
+ Shibuya.Adapter.Pgmq.DeadLetter: [afterCursor] :: DlqPageRequest -> !Maybe DlqCursor
+ Shibuya.Adapter.Pgmq.DeadLetter: [afterMessageId] :: DlqCursor -> !MessageId
+ Shibuya.Adapter.Pgmq.DeadLetter: [allowedSourceQueues] :: DlqRegistration -> ![QueueName]
+ Shibuya.Adapter.Pgmq.DeadLetter: [deadLetterQueue] :: DeadLetterItem -> !QueueName
+ Shibuya.Adapter.Pgmq.DeadLetter: [decodedEntry] :: DeadLetterItem -> !Either DlqDecodeError DeadLetterEntry
+ Shibuya.Adapter.Pgmq.DeadLetter: [dlqEnqueuedAt] :: DeadLetterEntry -> !UTCTime
+ Shibuya.Adapter.Pgmq.DeadLetter: [dlqLastReadAt] :: DeadLetterEntry -> !Maybe UTCTime
+ Shibuya.Adapter.Pgmq.DeadLetter: [dlqMessageId] :: DeadLetterEntry -> !MessageId
+ Shibuya.Adapter.Pgmq.DeadLetter: [dlqReadCount] :: DeadLetterEntry -> !Int64
+ Shibuya.Adapter.Pgmq.DeadLetter: [field] :: DlqDecodeError -> !Text
+ Shibuya.Adapter.Pgmq.DeadLetter: [items] :: DeadLetterPage -> !Vector DeadLetterItem
+ Shibuya.Adapter.Pgmq.DeadLetter: [lockTimeoutMs] :: DeadLetterAdminConfig -> !Int32
+ Shibuya.Adapter.Pgmq.DeadLetter: [nextCursor] :: DeadLetterPage -> !Maybe DlqCursor
+ Shibuya.Adapter.Pgmq.DeadLetter: [originalEnqueuedAt] :: DeadLetterEntry -> !Maybe UTCTime
+ Shibuya.Adapter.Pgmq.DeadLetter: [originalHeaders] :: DeadLetterEntry -> !Maybe Value
+ Shibuya.Adapter.Pgmq.DeadLetter: [originalLastReadAt] :: DeadLetterEntry -> !Maybe UTCTime
+ Shibuya.Adapter.Pgmq.DeadLetter: [originalMessageId] :: DeadLetterEntry -> !Maybe MessageId
+ Shibuya.Adapter.Pgmq.DeadLetter: [originalPayload] :: DeadLetterEntry -> !Value
+ Shibuya.Adapter.Pgmq.DeadLetter: [originalReadCount] :: DeadLetterEntry -> !Maybe Int64
+ Shibuya.Adapter.Pgmq.DeadLetter: [pageLimit] :: DlqPageRequest -> !Int32
+ Shibuya.Adapter.Pgmq.DeadLetter: [problem] :: DlqDecodeError -> !DlqDecodeProblem
+ Shibuya.Adapter.Pgmq.DeadLetter: [rawMessage] :: DeadLetterItem -> !Message
+ Shibuya.Adapter.Pgmq.DeadLetter: [reasonCode] :: DeadLetterEntry -> !Text
+ Shibuya.Adapter.Pgmq.DeadLetter: [reasonDetail] :: DeadLetterEntry -> !Value
+ Shibuya.Adapter.Pgmq.DeadLetter: [reasonRepresentation] :: DeadLetterEntry -> !ReasonRepresentation
+ Shibuya.Adapter.Pgmq.DeadLetter: [redriveEnabled] :: DeadLetterAdminConfig -> !Bool
+ Shibuya.Adapter.Pgmq.DeadLetter: [registrations] :: DeadLetterAdminConfig -> ![DlqRegistration]
+ Shibuya.Adapter.Pgmq.DeadLetter: [resolvedSource] :: DeadLetterEntry -> !Maybe QueueName
+ Shibuya.Adapter.Pgmq.DeadLetter: [sourceResolution] :: DeadLetterEntry -> !SourceResolution
+ Shibuya.Adapter.Pgmq.DeadLetter: [statementTimeoutMs] :: DeadLetterAdminConfig -> !Int32
+ Shibuya.Adapter.Pgmq.DeadLetter: browseDeadLetters :: DeadLetterAdmin -> QueueName -> DlqPageRequest -> IO (Either DlqError DeadLetterPage)
+ Shibuya.Adapter.Pgmq.DeadLetter: data DeadLetterAdmin
+ Shibuya.Adapter.Pgmq.DeadLetter: data DeadLetterAdminConfig
+ Shibuya.Adapter.Pgmq.DeadLetter: data DeadLetterEntry
+ Shibuya.Adapter.Pgmq.DeadLetter: data DeadLetterItem
+ Shibuya.Adapter.Pgmq.DeadLetter: data DeadLetterPage
+ Shibuya.Adapter.Pgmq.DeadLetter: data DlqConfigError
+ Shibuya.Adapter.Pgmq.DeadLetter: data DlqCursor
+ Shibuya.Adapter.Pgmq.DeadLetter: data DlqDecodeError
+ Shibuya.Adapter.Pgmq.DeadLetter: data DlqDecodeProblem
+ Shibuya.Adapter.Pgmq.DeadLetter: data DlqError
+ Shibuya.Adapter.Pgmq.DeadLetter: data DlqPageError
+ Shibuya.Adapter.Pgmq.DeadLetter: data DlqPageRequest
+ Shibuya.Adapter.Pgmq.DeadLetter: data DlqRegistration
+ Shibuya.Adapter.Pgmq.DeadLetter: data ReasonRepresentation
+ Shibuya.Adapter.Pgmq.DeadLetter: data RedriveOutcome
+ Shibuya.Adapter.Pgmq.DeadLetter: data SourceResolution
+ Shibuya.Adapter.Pgmq.DeadLetter: defaultDeadLetterAdminConfig :: [DlqRegistration] -> DeadLetterAdminConfig
+ Shibuya.Adapter.Pgmq.DeadLetter: defaultDlqPageRequest :: DlqPageRequest
+ Shibuya.Adapter.Pgmq.DeadLetter: fetchDeadLetter :: DeadLetterAdmin -> QueueName -> MessageId -> IO (Either DlqError DeadLetterItem)
+ Shibuya.Adapter.Pgmq.DeadLetter: mkDeadLetterAdmin :: Pool -> DeadLetterAdminConfig -> Either DlqConfigError DeadLetterAdmin
+ Shibuya.Adapter.Pgmq.DeadLetter: redriveDeadLetter :: DeadLetterAdmin -> QueueName -> MessageId -> IO (Either DlqError RedriveOutcome)
+ Shibuya.Adapter.Pgmq.DeadLetter: registrationFromAdapterConfig :: PgmqAdapterConfig -> Either DlqConfigError DlqRegistration

Files

CHANGELOG.md view
@@ -1,5 +1,41 @@ # Changelog +## 0.18.0.0 — 2026-10-27++### Breaking Changes++- Require [`shibuya-core`](mori://shinzui/shibuya/packages/shibuya-core) `^>=0.12.0.0`,+  adopting its extended configuration, processor metrics, paused-state API, and+  `Ingested.deliveryStatus` field. PGMQ deliveries report `DeliveryCurrent`,+  preserving handler and acknowledgement behavior.+- Require the [`pgmq-hs`](mori://shinzui/pgmq-hs) 0.7.0.0 package family+  (`pgmq-core`, `pgmq-hasql`, and `pgmq-effectful`; tests also use `pgmq-migration`).++### New Features++- Expose `Shibuya.Adapter.Pgmq.DeadLetter` for explicitly registered,+  non-destructive DLQ browse/fetch and default-disabled transactional redrive.+  Redrive authorizes the source queue and atomically claims each DLQ row before+  sending its original payload; concurrent calls and uncertain-commit retries+  cannot duplicate that physical row's redrive.+- Add `mkDlqPayloadForQueue` and include `original_queue` in adapter-produced+  DLQ envelopes in both metadata modes. Preserve `mkDlqPayload` and existing+  reason fields; historical and malformed envelopes remain inspectable.++### Other Changes++- Use the published dependency releases without the transitional `dlq-admin` flag.+- Extend PostgreSQL coverage for managed DLQs and consumer trace context;+  preserve core configuration defaults in lifecycle fixtures and use stable+  per-user temporary roots for ephemeral PostgreSQL cleanup.+- Add reproducible worker, administration, and encoding performance comparisons.+  Performance acceptance remains incomplete: 196 of 198 numeric gates pass;+  FIFO acknowledgement p95 upper bounds are 1.106857 against the original+  control and 1.172465 against the matched control, exceeding the 1.10 limit.+- Defer performance acceptance for this release by explicit user approval;+  retain the failed bounds and unchanged numeric limits.+- Consolidate the unpublished 0.17.0.0 changes into this release.+ ## 0.16.1.0 — 2026-09-21  ### Reliability
shibuya-pgmq-adapter.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.12 name: shibuya-pgmq-adapter-version: 0.16.1.0+version: 0.18.0.0 synopsis: PGMQ adapter for the Shibuya queue processing framework description:   A Shibuya adapter that integrates with pgmq (PostgreSQL Message Queue)@@ -23,8 +23,10 @@     Shibuya.Adapter.Pgmq     Shibuya.Adapter.Pgmq.Config     Shibuya.Adapter.Pgmq.Convert+    Shibuya.Adapter.Pgmq.DeadLetter    other-modules:+    Shibuya.Adapter.Pgmq.DeadLetter.Internal     Shibuya.Adapter.Pgmq.Internal    default-extensions:@@ -46,10 +48,10 @@     hasql ^>=1.10,     hasql-pool ^>=1.4,     hasql-transaction ^>=1.2,-    pgmq-core ^>=0.6,-    pgmq-effectful ^>=0.6,-    pgmq-hasql ^>=0.6,-    shibuya-core ^>=0.10.0.0,+    pgmq-core ^>=0.7.0.0,+    pgmq-effectful ^>=0.7.0.0,+    pgmq-hasql ^>=0.7.0.0,+    shibuya-core ^>=0.12.0.0,     stm ^>=2.5,     streamly ^>=0.11,     streamly-core ^>=0.3,@@ -75,6 +77,12 @@     src    main-is: Main.hs+  other-modules:+    DeadLetterExample+    Shibuya.Adapter.Pgmq.DeadLetter+    Shibuya.Adapter.Pgmq.DeadLetter.Internal+    Shibuya.Adapter.Pgmq.DeadLetterSpec+   default-extensions:     DeriveAnyClass     DerivingStrategies@@ -107,19 +115,21 @@     base ^>=4.21.0.0,     bytestring,     effectful-core (>=2.6.1 && <2.7) || (>=2.7.1.1 && <2.8),-    ephemeral-pg,+    ephemeral-pg >=0.3.1 && <0.4, directory, unix,     hasql ^>=1.10,     hasql-pool ^>=1.4,     hasql-transaction ^>=1.2,     hspec ^>=2.11,+    hs-opentelemetry-api ^>=1.0,+    hs-opentelemetry-exporter-in-memory ^>=1.0,     pg-migrate >=1.1 && <1.3,-    pgmq-core ^>=0.6,-    pgmq-effectful ^>=0.6,-    pgmq-hasql ^>=0.6,-    pgmq-migration ^>=0.6,+    pgmq-core ^>=0.7.0.0,+    pgmq-effectful ^>=0.7.0.0,+    pgmq-hasql ^>=0.7.0.0,+    pgmq-migration ^>=0.7.0.0,     quickcheck-instances ^>=0.3,     random,-    shibuya-core ^>=0.10.0.0,+    shibuya-core ^>=0.12.0.0,     shibuya-pgmq-adapter,     stm,     streamly ^>=0.11,
src/Shibuya/Adapter/Pgmq/Convert.hs view
@@ -13,6 +13,7 @@      -- * DLQ Payload     mkDlqPayload,+    mkDlqPayloadForQueue,   ) where @@ -169,3 +170,13 @@             "original_headers" .= msg.headers           ]       | otherwise = []++-- | Create an adapter-managed DLQ envelope with an authoritative source queue.+-- The original helper remains byte-for-byte compatible; source identity is+-- present independently of the optional delivery metadata.+mkDlqPayloadForQueue :: Pgmq.QueueName -> Pgmq.Message -> DeadLetterReason -> Bool -> Pgmq.MessageBody+mkDlqPayloadForQueue queue msg reason includeMetadata =+  case mkDlqPayload msg reason includeMetadata of+    Pgmq.MessageBody (Object fields) ->+      Pgmq.MessageBody (Object (KeyMap.insert "original_queue" (String (Pgmq.queueNameToText queue)) fields))+    body -> body
+ src/Shibuya/Adapter/Pgmq/DeadLetter.hs view
@@ -0,0 +1,39 @@+-- | In-process administration for explicitly registered adapter-managed DLQs.+--+-- Supply a separate administrative pool connected to the workers' database.+-- Constructing a handle performs no I/O and starts no background work. The host+-- owns and closes the pool. Separate connections do not isolate database CPU,+-- I/O or locks. Host transports must enforce their own caller permissions;+-- 'redriveEnabled' is a mutation gate, not authentication.+--+-- Pages are moving views ordered by queue-local message ID. Restart at the first+-- page to discover lower IDs committed late. Limits bound rows, not JSON bytes.+-- Archive recovery and queue discovery/creation are outside this API.+module Shibuya.Adapter.Pgmq.DeadLetter+  ( DeadLetterAdmin,+    DeadLetterAdminConfig (..),+    defaultDeadLetterAdminConfig,+    DlqRegistration (..),+    registrationFromAdapterConfig,+    DlqConfigError (..),+    mkDeadLetterAdmin,+    DlqCursor (..),+    DlqPageRequest (..),+    defaultDlqPageRequest,+    DeadLetterPage (..),+    DeadLetterItem (..),+    DeadLetterEntry (..),+    ReasonRepresentation (..),+    SourceResolution (..),+    DlqDecodeError (..),+    DlqDecodeProblem (..),+    DlqPageError (..),+    DlqError (..),+    RedriveOutcome (..),+    browseDeadLetters,+    fetchDeadLetter,+    redriveDeadLetter,+  )+where++import Shibuya.Adapter.Pgmq.DeadLetter.Internal
+ src/Shibuya/Adapter/Pgmq/DeadLetter/Internal.hs view
@@ -0,0 +1,354 @@+-- | Implementation of adapter-owned DLQ semantics and transaction composition.+module Shibuya.Adapter.Pgmq.DeadLetter.Internal where++import Control.Monad (unless, void, when)+import Data.Aeson (FromJSON, Result (..), Value (..), fromJSON)+import Data.Aeson.Key qualified as Key+import Data.Aeson.KeyMap qualified as KM+import Data.Functor.Contravariant (contramap)+import Data.Int (Int32, Int64)+import Data.List (nub)+import Data.Text (Text)+import Data.Text qualified as Text+import Data.Time (UTCTime)+import Data.Vector (Vector)+import Data.Vector qualified as Vector+import Hasql.Decoders qualified as D+import Hasql.Encoders qualified as E+import Hasql.Pool qualified as Pool+import Hasql.Statement qualified as Statement+import Hasql.Transaction qualified as Tx+import Hasql.Transaction.Sessions qualified as TxSession+import Pgmq.Effectful (PgmqRuntimeError, fromUsageError)+import Pgmq.Hasql.Decoders (messageDecoder)+import Pgmq.Hasql.Encoders (messageIdValue)+import Pgmq.Hasql.Statements.Inspection qualified as Inspection+import Pgmq.Hasql.Statements.Message qualified as Msg+import Pgmq.Hasql.Statements.Types qualified as Q+import Pgmq.Types (Message (..), MessageBody (..), MessageHeaders (..), MessageId (..), QueueName, parseQueueName, queueNameToText)+import Shibuya.Adapter.Pgmq.Config (DeadLetterConfig (..), DeadLetterTarget (..), PgmqAdapterConfig (..))++-- | One explicitly managed physical DLQ and the sources it may return to.+data DlqRegistration = DlqRegistration+  { deadLetterQueue :: !QueueName,+    allowedSourceQueues :: ![QueueName]+  }+  deriving stock (Eq, Show)++-- | Administrative settings, independent of worker configuration.+data DeadLetterAdminConfig = DeadLetterAdminConfig+  { registrations :: ![DlqRegistration],+    redriveEnabled :: !Bool,+    statementTimeoutMs :: !Int32,+    lockTimeoutMs :: !Int32+  }+  deriving stock (Eq, Show)++-- | Disable mutation; bound each statement to 5 seconds and lock waits to 1.+defaultDeadLetterAdminConfig :: [DlqRegistration] -> DeadLetterAdminConfig+defaultDeadLetterAdminConfig rs = DeadLetterAdminConfig rs False 5000 1000++-- | Invalid configuration is rejected without acquiring a connection.+data DlqConfigError+  = DuplicateManagedQueue !QueueName+  | EmptySourceQueues !QueueName+  | DuplicateSourceQueues !QueueName+  | SelfMapping !QueueName+  | InvalidStatementTimeout !Int32+  | InvalidLockTimeout !Int32+  | NoDirectDeadLetterTarget+  deriving stock (Eq, Show)++-- | Borrowed pool and validated configuration; no threads, queries or ownership.+data DeadLetterAdmin = DeadLetterAdmin !Pool.Pool !DeadLetterAdminConfig++-- | Validate registrations and deadlines without performing I/O. The supplied+-- pool is borrowed; the host remains responsible for its lifetime.+mkDeadLetterAdmin :: Pool.Pool -> DeadLetterAdminConfig -> Either DlqConfigError DeadLetterAdmin+mkDeadLetterAdmin pool config = do+  when (config.statementTimeoutMs <= 0) $ Left (InvalidStatementTimeout config.statementTimeoutMs)+  when (config.lockTimeoutMs <= 0) $ Left (InvalidLockTimeout config.lockTimeoutMs)+  let validate seen registration = do+        let queue = registration.deadLetterQueue+            sources = registration.allowedSourceQueues+        when (queue `elem` seen) $ Left (DuplicateManagedQueue queue)+        when (null sources) $ Left (EmptySourceQueues queue)+        when (sources /= nub sources) $ Left (DuplicateSourceQueues queue)+        when (queue `elem` sources) $ Left (SelfMapping queue)+        pure (queue : seen)+      go _ [] = pure ()+      go seen (r : rs) = validate seen r >>= \next -> go next rs+  go [] config.registrations+  pure (DeadLetterAdmin pool config)++-- | Derive the single-source registration for a direct adapter target.+-- Topic destinations require explicit physical registrations.+registrationFromAdapterConfig :: PgmqAdapterConfig -> Either DlqConfigError DlqRegistration+registrationFromAdapterConfig config = case config.deadLetterConfig of+  Just DeadLetterConfig {dlqTarget = DirectQueue queue}+    | queue /= config.queueName -> Right (DlqRegistration queue [config.queueName])+    | otherwise -> Left (SelfMapping queue)+  _ -> Left NoDirectDeadLetterTarget++-- | Exclusive cursor. IDs have meaning only within this physical queue's lifetime.+data DlqCursor = DlqCursor+  { deadLetterQueue :: !QueueName,+    afterMessageId :: !MessageId+  }+  deriving stock (Eq, Show)++-- | Positive row limit (at most 500), with an optional exclusive cursor.+data DlqPageRequest = DlqPageRequest+  { pageLimit :: !Int32,+    afterCursor :: !(Maybe DlqCursor)+  }+  deriving stock (Eq, Show)++-- | Start at the first ID and return at most 50 rows.+defaultDlqPageRequest :: DlqPageRequest+defaultDlqPageRequest = DlqPageRequest 50 Nothing++-- | Whether reason fields were stored explicitly or inferred from old text.+data ReasonRepresentation = StructuredReason | LegacyReason+  deriving stock (Eq, Show)++-- | How the source was resolved. A multi-source historical row remains unknown.+data SourceResolution = PayloadSource | ConfiguredFallback | SourceUnavailable+  deriving stock (Eq, Show)++-- | Stable, field-specific reasons an envelope cannot be interpreted safely.+data DlqDecodeProblem+  = MissingField+  | WrongType !Text+  | InvalidSourceName !Text+  | SourceNotAllowed !QueueName+  deriving stock (Eq, Show)++-- | The envelope field that could not be interpreted and its typed problem.+data DlqDecodeError = DlqDecodeError+  { field :: !Text,+    problem :: !DlqDecodeProblem+  }+  deriving stock (Eq, Show)++-- | Original delivery metadata and current DLQ metadata are deliberately separate.+-- Missing 'originalHeaders' is 'Nothing'; explicit JSON null is @Just Null@.+data DeadLetterEntry = DeadLetterEntry+  { originalPayload :: !Value,+    reasonCode :: !Text,+    reasonDetail :: !Value,+    reasonRepresentation :: !ReasonRepresentation,+    resolvedSource :: !(Maybe QueueName),+    sourceResolution :: !SourceResolution,+    originalMessageId :: !(Maybe MessageId),+    originalReadCount :: !(Maybe Int64),+    originalEnqueuedAt :: !(Maybe UTCTime),+    originalLastReadAt :: !(Maybe UTCTime),+    originalHeaders :: !(Maybe Value),+    dlqMessageId :: !MessageId,+    dlqReadCount :: !Int64,+    dlqEnqueuedAt :: !UTCTime,+    dlqLastReadAt :: !(Maybe UTCTime)+  }+  deriving stock (Eq, Show)++-- | Corrupt envelopes stay in their original position and retain their raw row.+data DeadLetterItem = DeadLetterItem+  { deadLetterQueue :: !QueueName,+    rawMessage :: !Message,+    decodedEntry :: !(Either DlqDecodeError DeadLetterEntry)+  }+  deriving stock (Eq, Show)++-- | A moving view, not a shared snapshot. Refresh page one for late lower IDs.+-- The row limit does not bound JSON bytes or database scan work.+data DeadLetterPage = DeadLetterPage+  { items :: !(Vector DeadLetterItem),+    nextCursor :: !(Maybe DlqCursor)+  }+  deriving stock (Eq, Show)++-- | Invalid row limit or a cursor bound to a different physical DLQ.+data DlqPageError = InvalidPageLimit !Int32 | CursorQueueMismatch !QueueName !QueueName+  deriving stock (Eq, Show)++-- | Database failures never become empty pages or successful absence.+data DlqError+  = RedriveDisabled+  | UnknownManagedDlq !QueueName+  | InvalidPageRequest !DlqPageError+  | EntryNotFound !QueueName !MessageId+  | MalformedEnvelope !DlqDecodeError+  | UnresolvedSource+  | DisallowedSource !QueueName+  | DatabaseError !PgmqRuntimeError+  deriving stock (Eq, Show)++-- | Absence cannot establish which actor removed the row.+data RedriveOutcome = Redriven !MessageId | AlreadyAbsent+  deriving stock (Eq, Show)++findRegistration :: DeadLetterAdminConfig -> QueueName -> Either DlqError DlqRegistration+findRegistration config queue = case filter (\r -> r.deadLetterQueue == queue) config.registrations of+  [registration] -> Right registration+  _ -> Left (UnknownManagedDlq queue)++decodeEntry :: DlqRegistration -> Message -> Either DlqDecodeError DeadLetterEntry+decodeEntry registration msg = case unMessageBody msg.body of+  Object fields -> do+    originalPayload <- required fields "original_message"+    (reasonCode, reasonDetail, reasonRepresentation) <- decodeReason fields+    (resolvedSource, sourceResolution) <- case KM.lookup "original_queue" fields of+      Nothing -> pure $ case registration.allowedSourceQueues of+        [source] -> (Just source, ConfiguredFallback)+        _ -> (Nothing, SourceUnavailable)+      Just value -> do+        name <- typed "original_queue" value+        source <- either (const $ bad "original_queue" (InvalidSourceName name)) pure (parseQueueName name)+        unless (source `elem` registration.allowedSourceQueues) $ bad "original_queue" (SourceNotAllowed source)+        pure (Just source, PayloadSource)+    originalMessageId <- fmap MessageId <$> optional fields "original_message_id"+    originalReadCount <- optional fields "read_count"+    originalEnqueuedAt <- optional fields "original_enqueued_at"+    originalLastReadAt <- case KM.lookup "last_read_at" fields of+      Nothing -> pure Nothing+      Just Null -> pure Nothing+      Just value -> Just <$> typed "last_read_at" value+    let originalHeaders = KM.lookup "original_headers" fields+    pure+      DeadLetterEntry+        { originalPayload,+          reasonCode,+          reasonDetail,+          reasonRepresentation,+          resolvedSource,+          sourceResolution,+          originalMessageId,+          originalReadCount,+          originalEnqueuedAt,+          originalLastReadAt,+          originalHeaders,+          dlqMessageId = msg.messageId,+          dlqReadCount = msg.readCount,+          dlqEnqueuedAt = msg.enqueuedAt,+          dlqLastReadAt = msg.lastReadAt+        }+  _ -> bad "$" (WrongType "object")+  where+    decodeReason fields+      | KM.member "dead_letter_reason_code" fields || KM.member "dead_letter_reason_detail" fields = do+          code <- required fields "dead_letter_reason_code" >>= typed "dead_letter_reason_code"+          detail <- required fields "dead_letter_reason_detail"+          pure (code, detail, StructuredReason)+      | otherwise = do+          rendered <- required fields "dead_letter_reason" >>= typed "dead_letter_reason"+          let (code, suffix) = Text.breakOn ": " rendered+          pure (code, if Text.null suffix then Null else String (Text.drop 2 suffix), LegacyReason)+    required fields key = maybe (bad (Key.toText key) MissingField) pure (KM.lookup key fields)+    optional fields key = traverse (typed (Key.toText key)) (KM.lookup key fields)++bad :: Text -> DlqDecodeProblem -> Either DlqDecodeError a+bad field problem = Left DlqDecodeError {field, problem}++typed :: (FromJSON a) => Text -> Value -> Either DlqDecodeError a+typed field value = case fromJSON value of+  Error err -> bad field (WrongType (Text.pack err))+  Success result -> Right result++inspectItem :: DlqRegistration -> Message -> DeadLetterItem+inspectItem registration msg = DeadLetterItem registration.deadLetterQueue msg (decodeEntry registration msg)++-- | Transaction-local settings are reset by PostgreSQL at commit or rollback.+setTimeouts :: DeadLetterAdminConfig -> Tx.Transaction ()+setTimeouts config =+  void $+    Tx.statement+      (Text.pack (show config.statementTimeoutMs) <> "ms", Text.pack (show config.lockTimeoutMs) <> "ms")+      ( Statement.preparable+          "select set_config('statement_timeout', $1, true), set_config('lock_timeout', $2, true)"+          (contramap fst (E.param (E.nonNullable E.text)) <> contramap snd (E.param (E.nonNullable E.text)))+          (D.singleRow ((,) <$> D.column (D.nonNullable D.text) <*> D.column (D.nonNullable D.text)))+      )++runTransaction :: DeadLetterAdmin -> TxSession.Mode -> Tx.Transaction (Either DlqError a) -> IO (Either DlqError a)+runTransaction (DeadLetterAdmin pool config) mode tx = do+  result <- Pool.use pool $ TxSession.transactionNoRetry TxSession.ReadCommitted mode (setTimeouts config >> tx)+  pure $ either (Left . DatabaseError . fromUsageError) id result++resolveTable :: QueueName -> Tx.Transaction Text+resolveTable queue = Tx.statement (queueNameToText queue, "q") Inspection.formatTableName++-- | Observe rows without leasing, including invisible and malformed rows.+browseDeadLetters :: DeadLetterAdmin -> QueueName -> DlqPageRequest -> IO (Either DlqError DeadLetterPage)+browseDeadLetters admin@(DeadLetterAdmin _ config) queue request = case validate of+  Left err -> pure (Left err)+  Right registration -> runTransaction admin TxSession.Read $ do+    table <- resolveTable queue+    rows <- Tx.statement (fmap (\cursor -> cursor.afterMessageId) request.afterCursor, request.pageLimit + 1) (Inspection.peekStatement table)+    let page = Vector.take (fromIntegral request.pageLimit) rows+        cursor =+          if Vector.length rows > fromIntegral request.pageLimit+            then (\msg -> DlqCursor queue msg.messageId) <$> (page Vector.!? (Vector.length page - 1))+            else Nothing+    pure $ Right (DeadLetterPage (Vector.map (inspectItem registration) page) cursor)+  where+    validate = do+      registration <- findRegistration config queue+      when (request.pageLimit < 1 || request.pageLimit > 500) $+        Left (InvalidPageRequest (InvalidPageLimit request.pageLimit))+      case request.afterCursor of+        Just cursor | cursor.deadLetterQueue /= queue -> Left (InvalidPageRequest (CursorQueueMismatch queue cursor.deadLetterQueue))+        _ -> pure ()+      pure registration++-- | Observe one registered DLQ row without leasing it. Malformed rows are+-- returned as inspectable items; only actual absence yields EntryNotFound.+fetchDeadLetter :: DeadLetterAdmin -> QueueName -> MessageId -> IO (Either DlqError DeadLetterItem)+fetchDeadLetter admin@(DeadLetterAdmin _ config) queue messageId = case findRegistration config queue of+  Left err -> pure (Left err)+  Right registration -> runTransaction admin TxSession.Read $ do+    table <- resolveTable queue+    result <- Tx.statement messageId (Inspection.lookupStatement table)+    pure $ maybe (Left (EntryNotFound queue messageId)) (Right . inspectItem registration) result++-- | Serialize on the selected row and atomically delete/send its original body.+-- Retry the complete operation after uncertain commit. A new ID/count/time and+-- tail position are assigned; distinct fan-out copies have distinct claims.+redriveDeadLetter :: DeadLetterAdmin -> QueueName -> MessageId -> IO (Either DlqError RedriveOutcome)+redriveDeadLetter admin@(DeadLetterAdmin _ config) queue messageId+  | not config.redriveEnabled = pure (Left RedriveDisabled)+  | otherwise = case findRegistration config queue of+      Left err -> pure (Left err)+      Right registration -> runTransaction admin TxSession.Write $ do+        table <- resolveTable queue+        locked <- Tx.statement messageId (lockedLookup table)+        case locked of+          Nothing -> pure (Right AlreadyAbsent)+          Just msg -> case decodeEntry registration msg of+            Left err -> pure $ Left $ case err.problem of+              SourceNotAllowed source -> DisallowedSource source+              _ -> MalformedEnvelope err+            Right entry -> case entry.resolvedSource of+              Nothing -> pure (Left UnresolvedSource)+              Just source -> do+                claimed <- Tx.statement (Q.MessageQuery queue messageId) Msg.deleteMessage+                if not claimed+                  then pure (Right AlreadyAbsent)+                  else do+                    let headers = case entry.originalHeaders of+                          Just saved -> Just saved+                          Nothing -> msg.headers+                    newId <- case headers of+                      Nothing -> Tx.statement (Q.SendMessage source (MessageBody entry.originalPayload) Nothing) Msg.sendMessage+                      Just value -> Tx.statement (Q.SendMessageWithHeaders source (MessageBody entry.originalPayload) (MessageHeaders value) Nothing) Msg.sendMessageWithHeaders+                    pure (Right (Redriven newId))++lockedLookup :: Text -> Statement.Statement MessageId (Maybe Message)+lockedLookup table =+  Statement.unpreparable+    ( "select msg_id, read_ct, enqueued_at, last_read_at, vt, message, headers from pgmq."+        <> Inspection.quoteIdentifier table+        <> " where msg_id = $1 for update"+    )+    (E.param (E.nonNullable messageIdValue))+    (D.rowMaybe messageDecoder)
src/Shibuya/Adapter/Pgmq/Internal.hs view
@@ -107,12 +107,12 @@     PollingConfig (..),   ) import Shibuya.Adapter.Pgmq.Convert-  ( mkDlqPayload,+  ( mkDlqPayloadForQueue,     pgmqMessageToEnvelope,   ) import Shibuya.Core.Ack (AckDecision (..), DeadLetterReason (..), RetryDelay (..)) import Shibuya.Core.AckHandle (AckHandle (..))-import Shibuya.Core.Ingested (Ingested (..))+import Shibuya.Core.Ingested (DeliveryStatus (..), Ingested (..)) import Shibuya.Core.Lease (Lease (..)) import Shibuya.Core.Types (TraceHeaders) import Shibuya.Telemetry.Effect (Tracing)@@ -309,7 +309,7 @@     Left err -> throwError (fromUsageError err)     Right () -> pure ()   where-    dlqBody = mkDlqPayload msg reason dlqConfig.includeMetadata+    dlqBody = mkDlqPayloadForQueue config.queueName msg reason dlqConfig.includeMetadata     sourceQuery = MessageQuery config.queueName msg.messageId     session =       Transaction.Sessions.transaction@@ -446,7 +446,8 @@           Ingested             { envelope = pgmqMessageToEnvelope msg,               ack = ackHandle,-              lease = Just lease+              lease = Just lease,+              deliveryStatus = pure DeliveryCurrent             }  finalizeAutoDeadLetter ::
+ test/DeadLetterExample.hs view
@@ -0,0 +1,14 @@+module DeadLetterExample (configureOrders, inspectOrders) where++import Hasql.Pool qualified as Pool+import Pgmq.Types (QueueName)+import Shibuya.Adapter.Pgmq.DeadLetter++configureOrders :: Pool.Pool -> QueueName -> QueueName -> Either DlqConfigError DeadLetterAdmin+configureOrders administrativePool source dlq =+  mkDeadLetterAdmin+    administrativePool+    (defaultDeadLetterAdminConfig [DlqRegistration dlq [source]])++inspectOrders :: DeadLetterAdmin -> QueueName -> IO (Either DlqError DeadLetterPage)+inspectOrders admin dlq = browseDeadLetters admin dlq defaultDlqPageRequest
test/Main.hs view
@@ -3,6 +3,7 @@ import Shibuya.Adapter.Pgmq.ChaosSpec qualified as ChaosSpec import Shibuya.Adapter.Pgmq.ConfigSpec qualified as ConfigSpec import Shibuya.Adapter.Pgmq.ConvertSpec qualified as ConvertSpec+import Shibuya.Adapter.Pgmq.DeadLetterSpec qualified as DeadLetterSpec import Shibuya.Adapter.Pgmq.IntegrationSpec qualified as IntegrationSpec import Shibuya.Adapter.Pgmq.InternalSpec qualified as InternalSpec import Shibuya.Adapter.Pgmq.PropertySpec qualified as PropertySpec@@ -18,3 +19,4 @@     describe "Shibuya.Adapter.Pgmq Properties" PropertySpec.spec   describe "Integration Tests" IntegrationSpec.spec   describe "Chaos Tests" ChaosSpec.spec+  DeadLetterSpec.spec
test/Shibuya/Adapter/Pgmq/ChaosSpec.hs view
@@ -15,10 +15,12 @@ import Control.Exception qualified as Exception import Control.Monad (forM_, when) import Data.Aeson (Value (..), object, (.=))+import Data.Aeson.Key qualified as Key import Data.Aeson.KeyMap qualified as KeyMap import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef) import Data.Int (Int32) import Data.Text qualified as Text+import Data.Text.Encoding qualified as Text import Data.Vector qualified as Vector import Effectful (Eff, IOE, Limit (..), Persistence (..), UnliftStrategy (..), liftIO, runEff, withEffToIO, (:>)) import Effectful.Dispatch.Dynamic (interpret)@@ -27,13 +29,17 @@ import Hasql.Pool qualified as Pool import Hasql.Session qualified as Session import Hasql.Statement qualified as Statement+import OpenTelemetry.Attributes (emptyAttributes)+import OpenTelemetry.Exporter.InMemory (inMemoryListExporter)+import OpenTelemetry.Trace.Core qualified as OTel+import OpenTelemetry.Trace.Id.Generator (IdGenerator (..)) import Pgmq.Effectful (Pgmq, PgmqRuntimeError, runPgmq) import Pgmq.Effectful qualified as PgmqEff import Pgmq.Effectful.Effect qualified as PgmqEffect import Pgmq.Hasql.Encoders qualified as Encoders import Pgmq.Hasql.Sessions qualified as Sessions-import Pgmq.Hasql.Statements.Types (ReadMessage (..), SendMessage (..), SendMessageWithHeaders (..))-import Pgmq.Types (Message (..), MessageBody (..), MessageHeaders (..), QueueName, parseQueueName)+import Pgmq.Hasql.Statements.Types (BindTopic (..), ReadMessage (..), SendMessage (..), SendMessageWithHeaders (..))+import Pgmq.Types (Message (..), MessageBody (..), MessageHeaders (..), QueueName, parseQueueName, parseRoutingKey, parseTopicPattern) import Shibuya.Adapter (Adapter (..)) import Shibuya.Adapter.Pgmq   ( PgmqAcknowledgementException,@@ -47,6 +53,7 @@     directDeadLetter,     mkPgmqAdapterEnv,     pgmqAdapter,+    topicDeadLetter,   ) import Shibuya.Adapter.Pgmq.Internal (mkAckHandle, mkIngested) import Shibuya.App@@ -67,7 +74,8 @@ import Shibuya.Core.Ingested (Ingested (..)) import Shibuya.Handler (Handler) import Shibuya.Internal.Runner.Master (getLifecycleSnapshot)-import Shibuya.Telemetry.Effect (Tracing, runTracingNoop)+import Shibuya.Telemetry.Effect (Tracing, runTracing, runTracingNoop)+import Shibuya.Telemetry.Propagation (extractTraceContext) import Streamly.Data.Stream qualified as Stream import System.Environment (lookupEnv) import System.Timeout (timeout)@@ -334,6 +342,72 @@             }     Vector.length sourceMessages `shouldBe` 0 +  forM_ [False, True] $ \topicRoute ->+    forM_ [False, True] $ \withOriginal ->+      it ("DLQ consumer trace " <> (if topicRoute then "topic" else "direct") <> (if withOriginal then " with producer" else " without headers")) $ \TestFixture {pool, queueName, dlqName} -> do+        let producerParent = "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"+            producerState = "vendor=opaque"+            original = object ["traceparent" .= (producerParent :: Text.Text), "tracestate" .= (producerState :: Text.Text), "custom" .= ("kept" :: Text.Text)]+            routingKey = either (error . show) id $ parseRoutingKey "audit.dlq"+            topicPattern = either (error . show) id $ parseTopicPattern "audit.dlq"+            config =+              (defaultConfig queueName)+                { visibilityTimeout = 5,+                  batchSize = 1,+                  polling = StandardPolling {pollInterval = 0.1},+                  deadLetterConfig = Just $ if topicRoute then topicDeadLetter routingKey True else directDeadLetter dlqName True+                }+        when topicRoute $ runPgmqSession pool $ Sessions.bindTopic $ BindTopic topicPattern dlqName+        runPgmqSession pool $ do+          if withOriginal+            then do+              _ <- Sessions.sendMessageWithHeaders $ SendMessageWithHeaders queueName (MessageBody (String "traced-dlq")) (MessageHeaders original) (Just 0)+              pure ()+            else do+              _ <- Sessions.sendMessage $ SendMessage queueName (MessageBody (String "traced-dlq")) (Just 0)+              pure ()+        (spanProcessor, spansRef) <- inMemoryListExporter+        processedRef <- newIORef (0 :: Int)+        Exception.bracket+          (OTel.createTracerProvider [spanProcessor] OTel.emptyTracerProviderOptions {OTel.tracerProviderOptionsIdGenerator = DefaultIdGenerator})+          (\p -> OTel.shutdownTracerProvider p (Just 5_000_000))+          $ \provider -> do+            let tracer = OTel.makeTracer provider (OTel.InstrumentationLibrary "dlq-audit" "" "" emptyAttributes) OTel.tracerOptions+            runAdapterIO pool $ runTracing tracer $ do+              adapter <- requireAdapter pool config+              result <- runApp defaultAppConfig [(ProcessorId "dlq-identity", mkProcessor adapter (deadLetterHandler processedRef))]+              case result of+                Left err -> liftIO $ expectationFailure $ show err+                Right appHandle -> do+                  liftIO $ waitForProcessed processedRef 1 3_000_000+                  drained <- stopAppGracefully defaultShutdownConfig {drainTimeout = 5} appHandle+                  liftIO $ drained `shouldBe` True+        messages <- runPgmqSession pool $ Sessions.readMessage $ ReadMessage dlqName 30 (Just 10) Nothing+        Vector.length messages `shouldBe` 1+        spans <- readIORef spansRef+        named <- mapM (\sp -> do hot <- readIORef (OTel.spanHot sp); pure (OTel.hotName hot, sp)) spans+        case (Vector.uncons messages, [sp | ("dlq-identity process", sp) <- named]) of+          (Just (msg, _), [consumer]) -> case msg.headers of+            Just (Object hdrs) -> do+              let stringHeader k = case KeyMap.lookup k hdrs of+                    Just (String v) -> Just (Text.encodeUtf8 v)+                    _ -> Nothing+                  outgoing = [(Text.encodeUtf8 (Key.toText k), v) | k <- ["traceparent", "tracestate"], Just v <- [stringHeader k]]+              case extractTraceContext outgoing of+                Nothing -> expectationFailure "expected valid consumer context in DLQ"+                Just ctx -> do+                  OTel.traceId ctx `shouldBe` OTel.traceId (OTel.spanContext consumer)+                  OTel.spanId ctx `shouldBe` OTel.spanId (OTel.spanContext consumer)+              if withOriginal+                then do+                  KeyMap.lookup "x-shibuya-upstream-traceparent" hdrs `shouldBe` Just (String producerParent)+                  KeyMap.lookup "x-shibuya-upstream-tracestate" hdrs `shouldBe` Just (String producerState)+                  KeyMap.lookup "custom" hdrs `shouldBe` Just (String "kept")+                  stringHeader "traceparent" `shouldNotBe` Just (Text.encodeUtf8 producerParent)+                else KeyMap.lookup "x-shibuya-upstream-traceparent" hdrs `shouldBe` Nothing+            _ -> expectationFailure "expected DLQ headers"+          _ -> expectationFailure "expected one DLQ message and one consumer span"+   it "preserves trace headers when moving to DLQ" $ \TestFixture {pool, queueName, dlqName} -> do     -- Send a message with trace headers     let traceparent = "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01" :: Text.Text@@ -1162,7 +1236,7 @@       adapter <- requireAdapter pool config       let handler = slowHandler processedRef 10_000_000           processor = mkProcessor adapter handler-      appResult <- runApp (AppConfig {strategy = IgnoreFailures, inboxSize = 2}) [(ProcessorId "noloss-test", processor)]+      appResult <- runApp (defaultAppConfig {strategy = IgnoreFailures, inboxSize = 2}) [(ProcessorId "noloss-test", processor)]       case appResult of         Left err -> liftIO $ expectationFailure $ "Failed to start app: " <> show err         Right appHandle -> do@@ -1328,7 +1402,7 @@     adapter <- requireAdapter pool config     let handler = slowHandler processedRef 400000 -- 0.4s per message         processor = mkProcessor adapter handler-    appResult <- runApp (AppConfig {strategy = IgnoreFailures, inboxSize = 2}) [(ProcessorId "shutdown-measure", processor)]+    appResult <- runApp (defaultAppConfig {strategy = IgnoreFailures, inboxSize = 2}) [(ProcessorId "shutdown-measure", processor)]     case appResult of       Left err -> liftIO $ expectationFailure $ "Failed to start app: " <> show err       Right appHandle -> do
test/Shibuya/Adapter/Pgmq/ConvertSpec.hs view
@@ -21,6 +21,7 @@   extractTraceHeadersSpec   pgmqMessageToEnvelopeSpec   mkDlqPayloadSpec+  mkDlqPayloadForQueueSpec  -- | Tests for messageIdToShibuya messageIdToShibuyaSpec :: Spec@@ -455,3 +456,40 @@   case mkDeadLetterCode "keiro.router.selection.recipient_overflow" of     Left err -> error (Text.unpack err)     Right code -> code++mkDlqPayloadForQueueSpec :: Spec+mkDlqPayloadForQueueSpec = describe "mkDlqPayloadForQueue" $ do+  let timestamp = UTCTime (fromGregorian 2026 10 25) 0+      queue = either (error . show) id (Pgmq.parseQueueName "orders")+      msg =+        Pgmq.Message+          (Pgmq.MessageId 42)+          timestamp+          timestamp+          (Just timestamp)+          3+          (Pgmq.MessageBody (object ["order" .= (7 :: Int)]))+          (Just (object ["x-pgmq-group" .= ("a" :: Text.Text)]))+      reason = ApplicationFailure representativeDeadLetterCode "注文: failed"+      baseFields =+        [ "original_queue" .= ("orders" :: Text.Text),+          "original_message" .= object ["order" .= (7 :: Int)],+          "dead_letter_reason" .= ("keiro.router.selection.recipient_overflow: 注文: failed" :: Text.Text),+          "dead_letter_reason_code" .= ("keiro.router.selection.recipient_overflow" :: Text.Text),+          "dead_letter_reason_detail" .= ("注文: failed" :: Text.Text)+        ]+  it "writes the exact queue-aware envelope without optional metadata" $+    mkDlqPayloadForQueue queue msg reason False `shouldBe` Pgmq.MessageBody (object baseFields)+  it "writes the exact queue-aware envelope with original metadata" $+    mkDlqPayloadForQueue queue msg reason True+      `shouldBe` Pgmq.MessageBody+        ( object+            ( baseFields+                ++ [ "original_message_id" .= (42 :: Int),+                     "original_enqueued_at" .= timestamp,+                     "last_read_at" .= timestamp,+                     "read_count" .= (3 :: Int),+                     "original_headers" .= object ["x-pgmq-group" .= ("a" :: Text.Text)]+                   ]+            )+        )
+ test/Shibuya/Adapter/Pgmq/DeadLetterSpec.hs view
@@ -0,0 +1,549 @@+module Shibuya.Adapter.Pgmq.DeadLetterSpec (spec) where++import Control.Concurrent (threadDelay)+import Control.Concurrent.Async (async, cancel, concurrently, waitCatch, withAsync)+import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar)+import Control.Exception (bracket, throwIO)+import Control.Monad (forM_, void)+import Data.Aeson (Value (..), object, (.=))+import Data.Aeson.Key qualified+import Data.Aeson.KeyMap qualified as KM+import Data.Either (isLeft)+import Data.Text (Text)+import Data.Time (secondsToDiffTime)+import Data.Vector qualified as V+import Effectful (liftIO, runEff)+import Effectful.Error.Static (runErrorNoCallStack)+import Hasql.Connection.Settings qualified as Settings+import Hasql.Decoders qualified as D+import Hasql.Encoders qualified as E+import Hasql.Pool qualified as Pool+import Hasql.Pool.Config qualified as PoolConfig+import Hasql.Session qualified as Session+import Hasql.Statement qualified as Statement+import Pgmq.Effectful (PgmqRuntimeError, runPgmq)+import Pgmq.Hasql.Sessions qualified as S+import Pgmq.Hasql.Statements.Inspection qualified as Inspection+import Pgmq.Hasql.Statements.Types qualified as Q+import Pgmq.Types qualified as P+import Shibuya.Adapter.Pgmq+import Shibuya.Adapter.Pgmq.DeadLetter+import Shibuya.App (ProcessorId (..), defaultAppConfig, defaultShutdownConfig, mkProcessor, runApp, stopAppGracefully)+import Shibuya.Core.Ack (AckDecision (..), DeadLetterReason (..), mkDeadLetterCode)+import Shibuya.Core.Ingested (Message (..))+import Shibuya.Core.Types (Envelope (..))+import Shibuya.Telemetry.Effect (runTracingNoop)+import System.Timeout (timeout)+import Test.Hspec hiding (before)+import TmpPostgres++spec :: Spec+spec = describe "Dead-letter administration" $ do+  around database $ do+    it "preserves an application's exact reason through a real handler, browse and fetch" $ \fixture -> do+      let pool = fixture.pool+          source = fixture.queueName+          dlq = fixture.dlqName+          payload = object ["order" .= (42 :: Int)]+          detail = "注文の検証: failed: exact text"+          code = either (error . show) id (mkDeadLetterCode "orders.validation.failed")+          config =+            (defaultConfig source)+              { batchSize = 1,+                polling = StandardPolling 0.01,+                deadLetterConfig = Just (directDeadLetter dlq True)+              }+      void $ runPgmqSession pool $ S.sendMessage (Q.SendMessage source (P.MessageBody payload) Nothing)+      outcome <- runEff $ runErrorNoCallStack @PgmqRuntimeError $ runPgmq pool $ runTracingNoop $ do+        adapter <- pgmqAdapter (mkPgmqAdapterEnv pool) config >>= either (error . show) pure+        app <- runApp defaultAppConfig [(ProcessorId "dlq-roundtrip", mkProcessor adapter (\_ -> pure (AckDeadLetter (ApplicationFailure code detail))))] >>= either (error . show) pure+        liftIO $ awaitDlq pool dlq+        drained <- stopAppGracefully defaultShutdownConfig app+        liftIO $ drained `shouldBe` True+      either throwIO pure outcome+      admin <- requireAdmin fixture False+      page <- browseDeadLetters admin dlq defaultDlqPageRequest >>= right+      V.length page.items `shouldBe` 1+      let item = V.head page.items+      entry <- right item.decodedEntry+      entry.reasonCode `shouldBe` "orders.validation.failed"+      entry.reasonDetail `shouldBe` String detail+      entry.originalPayload `shouldBe` payload+      entry.resolvedSource `shouldBe` Just source+      entry.sourceResolution `shouldBe` PayloadSource+      entry.originalReadCount `shouldBe` Just 1+      entry.dlqReadCount `shouldBe` 0+      entry.dlqEnqueuedAt `shouldBe` item.rawMessage.enqueuedAt+      fetchDeadLetter admin dlq item.rawMessage.messageId `shouldReturn` Right item+      redriveDeadLetter admin dlq item.rawMessage.messageId `shouldReturn` Left RedriveDisabled++    it "pages by exclusive IDs across malformed and invisible rows without changing any row" $ \fixture -> do+      admin <- requireAdmin fixture False+      browseDeadLetters admin fixture.dlqName defaultDlqPageRequest `shouldReturn` Right (DeadLetterPage V.empty Nothing)+      firstId <- seed fixture (envelope (String "first"))+      corruptId <- seed fixture (String "not an envelope")+      lastId <- seed fixture (envelope Null)+      void $ runPgmqSession fixture.pool $ S.readMessage (Q.ReadMessage fixture.dlqName 300 (Just 2) Nothing)+      before <- snapshot fixture.pool fixture.dlqName+      firstPage <- browseDeadLetters admin fixture.dlqName (DlqPageRequest 2 Nothing) >>= right+      V.map (\item -> item.rawMessage.messageId) firstPage.items `shouldBe` V.fromList [firstId, corruptId]+      firstPage.nextCursor `shouldBe` Just (DlqCursor fixture.dlqName corruptId)+      (firstPage.items V.! 1).decodedEntry `shouldSatisfy` isLeft+      lastPage <- browseDeadLetters admin fixture.dlqName (DlqPageRequest 2 firstPage.nextCursor) >>= right+      V.map (\item -> item.rawMessage.messageId) lastPage.items `shouldBe` V.singleton lastId+      lastPage.nextCursor `shouldBe` Nothing+      nullEntry <- right (V.head lastPage.items).decodedEntry+      nullEntry.originalPayload `shouldBe` Null+      forM_ before $ \msg -> void $ fetchDeadLetter admin fixture.dlqName msg.messageId >>= right+      snapshot fixture.pool fixture.dlqName `shouldReturn` before+      void $ runPgmqSession fixture.pool $ S.deleteMessage (Q.MessageQuery fixture.dlqName firstId)+      browseDeadLetters admin fixture.dlqName (DlqPageRequest 2 firstPage.nextCursor) `shouldReturn` Right lastPage+      exact <- browseDeadLetters admin fixture.dlqName (DlqPageRequest 2 Nothing) >>= right+      V.length exact.items `shouldBe` 2+      exact.nextCursor `shouldBe` Nothing+      fetchDeadLetter admin fixture.dlqName firstId `shouldReturn` Left (EntryNotFound fixture.dlqName firstId)++    it "decodes structured-only, dual-written and legacy history while preserving arbitrary JSON detail" $ \fixture -> do+      admin <- requireAdmin fixture False+      let detail = object ["nested" .= [Null, object ["ok" .= True]]]+          structured = object ["original_message" .= Null, "dead_letter_reason_code" .= ("invalid_payload" :: Text), "dead_letter_reason_detail" .= detail]+          legacy = object ["original_message" .= String "old", "dead_letter_reason" .= ("poison_pill: 原因: retained" :: Text)]+          bare = object ["original_message" .= Null, "dead_letter_reason" .= ("max_retries_exceeded" :: Text)]+      ids <- mapM (seed fixture) [structured, putField "dead_letter_reason" (String "ignored: legacy") structured, legacy, bare]+      entries <- mapM (\messageId -> fetchDeadLetter admin fixture.dlqName messageId >>= right >>= right . (\item -> item.decodedEntry)) ids+      map (\entry -> entry.reasonCode) entries `shouldBe` ["invalid_payload", "invalid_payload", "poison_pill", "max_retries_exceeded"]+      map (\entry -> entry.reasonDetail) entries `shouldBe` [detail, detail, String "原因: retained", Null]+      map (\entry -> entry.reasonRepresentation) entries `shouldBe` [StructuredReason, StructuredReason, LegacyReason, LegacyReason]+      forM_ entries $ \entry -> do+        entry.originalReadCount `shouldBe` Nothing+        entry.originalMessageId `shouldBe` Nothing+        entry.originalEnqueuedAt `shouldBe` Nothing+        entry.dlqReadCount `shouldBe` 0+        entry.sourceResolution `shouldBe` ConfiguredFallback+        entry.resolvedSource `shouldBe` Just fixture.queueName++    it "keeps malformed optional metadata and partial structured reasons inspectable and refuses redrive" $ \fixture -> do+      admin <- requireAdmin fixture True+      let payload = envelope (String "valid body")+          corrupt =+            [ ("original_message", deleteField "original_message" payload),+              ("dead_letter_reason_code", deleteField "dead_letter_reason_code" payload),+              ("dead_letter_reason_detail", deleteField "dead_letter_reason_detail" payload),+              ("dead_letter_reason_code", putField "dead_letter_reason_code" Null payload),+              ("original_queue", putField "original_queue" (Number 42) payload),+              ("original_queue", putField "original_queue" (String "../bad") payload),+              ("read_count", putField "read_count" (String "2") payload),+              ("original_message_id", putField "original_message_id" Null payload),+              ("original_enqueued_at", putField "original_enqueued_at" (String "not-a-time") payload),+              ("last_read_at", putField "last_read_at" (Number 7) payload)+            ]+      forM_ corrupt $ \(field, body) -> do+        messageId <- seed fixture body+        item <- fetchDeadLetter admin fixture.dlqName messageId >>= right+        case item.decodedEntry of+          Right _ -> expectationFailure "corrupt envelope decoded successfully"+          Left err -> do+            err.field `shouldBe` field+            redriveDeadLetter admin fixture.dlqName messageId `shouldReturn` Left (MalformedEnvelope err)+      rows <- snapshot fixture.pool fixture.dlqName+      page <- browseDeadLetters admin fixture.dlqName defaultDlqPageRequest >>= right+      V.length page.items `shouldBe` length corrupt+      snapshot fixture.pool fixture.dlqName `shouldReturn` rows++    it "validates limits, queue-bound cursors and managed queue identity before querying" $ \fixture -> do+      admin <- requireAdmin fixture False+      forM_ [0, -1, 501, maxBound] $ \limit ->+        browseDeadLetters admin fixture.dlqName (DlqPageRequest limit Nothing) `shouldReturn` Left (InvalidPageRequest (InvalidPageLimit limit))+      browseDeadLetters admin fixture.dlqName (DlqPageRequest 50 (Just (DlqCursor fixture.queueName (P.MessageId 1))))+        `shouldReturn` Left (InvalidPageRequest (CursorQueueMismatch fixture.dlqName fixture.queueName))+      browseDeadLetters admin fixture.queueName defaultDlqPageRequest `shouldReturn` Left (UnknownManagedDlq fixture.queueName)+      fetchDeadLetter admin fixture.queueName (P.MessageId 1) `shouldReturn` Left (UnknownManagedDlq fixture.queueName)+      redriveDeadLetter admin fixture.queueName (P.MessageId 1) `shouldReturn` Left RedriveDisabled++    it "does not turn missing tables into an empty page or EntryNotFound" $ \fixture -> do+      admin <- requireAdmin fixture False+      void $ runPgmqSession fixture.pool $ S.dropQueue fixture.dlqName+      browseDeadLetters admin fixture.dlqName defaultDlqPageRequest >>= (`shouldSatisfy` isDatabaseError)+      fetchDeadLetter admin fixture.dlqName (P.MessageId 1) >>= (`shouldSatisfy` isDatabaseError)+      runPgmqSession fixture.pool $ S.createQueue fixture.dlqName++    it "requires valid direct mappings and positive deadlines at handle construction" $ \fixture -> do+      let queue = fixture.dlqName+          source = fixture.queueName+          registration = DlqRegistration queue [source]+          config = defaultDeadLetterAdminConfig [registration]+          rejects cfg expected = case mkDeadLetterAdmin fixture.pool cfg of+            Left err -> err `shouldBe` expected+            Right _ -> expectationFailure "invalid config accepted"+      rejects (config {registrations = [registration, registration]}) (DuplicateManagedQueue queue)+      rejects (config {registrations = [DlqRegistration queue []]}) (EmptySourceQueues queue)+      rejects (config {registrations = [DlqRegistration queue [source, source]]}) (DuplicateSourceQueues queue)+      rejects (config {registrations = [DlqRegistration queue [queue]]}) (SelfMapping queue)+      rejects (config {statementTimeoutMs = 0}) (InvalidStatementTimeout 0)+      rejects (config {lockTimeoutMs = -1}) (InvalidLockTimeout (-1))+      registrationFromAdapterConfig (defaultConfig source) `shouldBe` Left NoDirectDeadLetterTarget+      registrationFromAdapterConfig ((defaultConfig source) {deadLetterConfig = Just (directDeadLetter queue False)}) `shouldBe` Right registration+      let key = either (error . show) id (P.parseRoutingKey "fail.orders")+      registrationFromAdapterConfig ((defaultConfig source) {deadLetterConfig = Just (topicDeadLetter key True)}) `shouldBe` Left NoDirectDeadLetterTarget+      registrationFromAdapterConfig ((defaultConfig source) {deadLetterConfig = Just (directDeadLetter source True)}) `shouldBe` Left (SelfMapping source)++    it "resolves registered shared DLQs by payload and refuses ambiguous or disallowed sources" $ \fixture -> do+      let other = namedQueue "another_source"+          forbidden = namedQueue "unregistered_source"+      admin <-+        right $+          mkDeadLetterAdmin fixture.pool $+            (defaultDeadLetterAdminConfig [DlqRegistration fixture.dlqName [fixture.queueName, other]]) {redriveEnabled = True}+      explicit <- seed fixture (putField "original_queue" (String (P.queueNameToText other)) (envelope Null))+      ambiguous <- seed fixture (envelope Null)+      disallowed <- seed fixture (putField "original_queue" (String (P.queueNameToText forbidden)) (envelope Null))+      item <- fetchDeadLetter admin fixture.dlqName explicit >>= right+      entry <- right item.decodedEntry+      entry.resolvedSource `shouldBe` Just other+      entry.sourceResolution `shouldBe` PayloadSource+      rows <- snapshot fixture.pool fixture.dlqName+      redriveDeadLetter admin fixture.dlqName ambiguous `shouldReturn` Left UnresolvedSource+      redriveDeadLetter admin fixture.dlqName disallowed `shouldReturn` Left (DisallowedSource forbidden)+      snapshot fixture.pool fixture.dlqName `shouldReturn` rows++    it "redrives the original payload once and preserves saved group/trace headers" $ \fixture -> do+      admin <- requireAdmin fixture True+      let body = object ["order" .= (42 :: Int)]+          saved = object ["x-pgmq-group" .= ("customer-a" :: Text), "traceparent" .= ("original" :: Text)]+          wrapper = putField "original_headers" saved (envelope body)+      messageId <- seedWithHeaders fixture wrapper (object ["traceparent" .= ("dlq" :: Text)])+      before <- snapshot fixture.pool fixture.dlqName+      disabled <- requireAdmin fixture False+      redriveDeadLetter disabled fixture.dlqName messageId `shouldReturn` Left RedriveDisabled+      snapshot fixture.pool fixture.dlqName `shouldReturn` before+      result <- redriveDeadLetter admin fixture.dlqName messageId >>= right+      newId <- case result of+        Redriven value -> pure value+        AlreadyAbsent -> expectationFailure "fresh row absent" >> fail "absent"+      snapshot fixture.pool fixture.dlqName `shouldReturn` V.empty+      sourceRows <- snapshot fixture.pool fixture.queueName+      V.length sourceRows `shouldBe` 1+      let restored = V.head sourceRows+      restored.messageId `shouldBe` newId+      restored.body `shouldBe` P.MessageBody body+      restored.headers `shouldBe` Just saved+      restored.readCount `shouldBe` 0+      redriveDeadLetter admin fixture.dlqName messageId `shouldReturn` Right AlreadyAbsent+      -- Discarded confirmed response followed by a fresh retry, not network loss.+      redriveDeadLetter admin fixture.dlqName messageId `shouldReturn` Right AlreadyAbsent+      snapshot fixture.pool fixture.queueName `shouldReturn` sourceRows+      consumed <- runPgmqSession fixture.pool $ S.readMessage (Q.ReadMessage fixture.queueName 30 (Just 1) Nothing)+      V.map (\msg -> msg.body) consumed `shouldBe` V.singleton (P.MessageBody body)++    it "distinguishes missing original headers from explicit null and accepts null payload" $ \fixture -> do+      admin <- requireAdmin fixture True+      let trace = object ["traceparent" .= ("dlq-span" :: Text), "x-pgmq-group" .= ("g" :: Text)]+      explicitNull <- seedWithHeaders fixture (putField "original_headers" Null (envelope Null)) trace+      missing <- seedWithHeaders fixture (envelope (String "metadata-disabled")) trace+      forM_ [explicitNull, missing] $ \messageId -> void $ redriveDeadLetter admin fixture.dlqName messageId >>= right+      rows <- snapshot fixture.pool fixture.queueName+      V.map (\msg -> msg.body) rows `shouldBe` V.fromList [P.MessageBody Null, P.MessageBody (String "metadata-disabled")]+      V.map (\msg -> msg.headers) rows `shouldBe` V.fromList [Just Null, Just trace]++    it "restores arbitrary JSON-valued saved headers supported by PGMQ" $ \fixture -> do+      admin <- requireAdmin fixture True+      let headers = [String "scalar", Array (V.fromList [Null, Bool True]), Number 7, Bool False]+      forM_ headers $ \saved -> do+        messageId <- seed fixture (putField "original_headers" saved (envelope Null))+        redriveDeadLetter admin fixture.dlqName messageId >>= (`shouldSatisfy` isRedriven)+      rows <- snapshot fixture.pool fixture.queueName+      V.map (\msg -> msg.headers) rows `shouldBe` V.fromList (map Just headers)++    it "serializes independent concurrent redrives on one durable row claim" $ \fixture -> do+      admin <- requireAdmin fixture True+      messageId <- seed fixture (envelope (String "race"))+      results <- concurrently (redriveDeadLetter admin fixture.dlqName messageId) (redriveDeadLetter admin fixture.dlqName messageId)+      case results of+        (Right (Redriven _), Right AlreadyAbsent) -> pure ()+        (Right AlreadyAbsent, Right (Redriven _)) -> pure ()+        _ -> expectationFailure (show results)+      rows <- snapshot fixture.pool fixture.queueName+      V.length rows `shouldBe` 1+      snapshot fixture.pool fixture.dlqName `shouldReturn` V.empty++    it "rolls the deletion back on send failure and can retry after the source is recreated" $ \fixture -> do+      admin <- requireAdmin fixture True+      messageId <- seed fixture (envelope (String "rollback"))+      before <- snapshot fixture.pool fixture.dlqName+      void $ runPgmqSession fixture.pool $ S.dropQueue fixture.queueName+      redriveDeadLetter admin fixture.dlqName messageId >>= (`shouldSatisfy` isDatabaseError)+      snapshot fixture.pool fixture.dlqName `shouldReturn` before+      runPgmqSession fixture.pool $ S.createQueue fixture.queueName+      redriveDeadLetter admin fixture.dlqName messageId >>= (`shouldSatisfy` isRedriven)+      rows <- snapshot fixture.pool fixture.queueName+      V.map (\msg -> msg.body) rows `shouldBe` V.singleton (P.MessageBody (String "rollback"))++    it "treats topic fan-out copies as two separately redrivable entries" $ \fixture -> do+      let other = namedQueue "topic_dlq_copy"+          key = either (error . show) id (P.parseRoutingKey "fail.orders")+          pattern' = either (error . show) id (P.parseTopicPattern "fail.#")+      runPgmqSession fixture.pool $ do+        S.createQueue other+        S.bindTopic (Q.BindTopic pattern' fixture.dlqName)+        S.bindTopic (Q.BindTopic pattern' other)+      produceFailure+        fixture+        ( (defaultConfig fixture.queueName)+            { batchSize = 1,+              polling = StandardPolling 0.01,+              deadLetterConfig = Just (topicDeadLetter key True)+            }+        )+        (String "fanout")+      admin <-+        right $+          mkDeadLetterAdmin fixture.pool $+            (defaultDeadLetterAdminConfig [DlqRegistration q [fixture.queueName] | q <- [fixture.dlqName, other]]) {redriveEnabled = True}+      copies <-+        mapM+          ( \q -> do+              page <- browseDeadLetters admin q defaultDlqPageRequest >>= right+              V.length page.items `shouldBe` 1+              pure (q, (V.head page.items).rawMessage.messageId)+          )+          [fixture.dlqName, other]+      ((q1, id1), (q2, id2)) <- case copies of+        [first, second] -> pure (first, second)+        _ -> expectationFailure "expected exactly two topic copies" >> fail "copy count"+      (r1, r2) <- concurrently (redriveDeadLetter admin q1 id1) (redriveDeadLetter admin q2 id2)+      r1 `shouldSatisfy` isRedriven+      r2 `shouldSatisfy` isRedriven+      rows <- snapshot fixture.pool fixture.queueName+      V.map (\msg -> msg.body) rows `shouldBe` V.replicate 2 (P.MessageBody (String "fanout"))+      void $ runPgmqSession fixture.pool $ S.dropQueue other++    it "redrives real metadata-disabled writes through a normal handler with the original payload" $ \fixture -> do+      let config =+            (defaultConfig fixture.queueName)+              { batchSize = 1,+                polling = StandardPolling 0.01,+                deadLetterConfig = Just (directDeadLetter fixture.dlqName False)+              }+      produceFailure fixture config (String "no metadata")+      admin <- requireAdmin fixture True+      page <- browseDeadLetters admin fixture.dlqName defaultDlqPageRequest >>= right+      let item = V.head page.items+      entry <- right item.decodedEntry+      entry.originalReadCount `shouldBe` Nothing+      entry.originalHeaders `shouldBe` Nothing+      entry.sourceResolution `shouldBe` PayloadSource+      redriveDeadLetter admin fixture.dlqName item.rawMessage.messageId >>= (`shouldSatisfy` isRedriven)+      -- Run an ordinary handler against the source and observe its public input.+      received <- newEmptyMVar+      outcome <- runEff $ runErrorNoCallStack @PgmqRuntimeError $ runPgmq fixture.pool $ runTracingNoop $ do+        adapter <- pgmqAdapter (mkPgmqAdapterEnv fixture.pool) config >>= either (error . show) pure+        app <- runApp defaultAppConfig [(ProcessorId "redriven-input", mkProcessor adapter (\message -> liftIO (putMVar received message.envelope.payload) >> pure AckOk))] >>= either (error . show) pure+        actual <- liftIO $ timeout 5000000 (takeMVar received)+        liftIO $ actual `shouldBe` Just (String "no metadata")+        void $ stopAppGracefully defaultShutdownConfig app+      either throwIO pure outcome++    it "includes source identity for automatic dead-letter moves" $ \fixture -> do+      let config =+            (defaultConfig fixture.queueName)+              { maxRetries = 0,+                batchSize = 1,+                polling = StandardPolling 0.01,+                deadLetterConfig = Just (directDeadLetter fixture.dlqName False)+              }+      produceFailure fixture config (String "automatic")+      admin <- requireAdmin fixture False+      page <- browseDeadLetters admin fixture.dlqName defaultDlqPageRequest >>= right+      entry <- right (V.head page.items).decodedEntry+      entry.reasonCode `shouldBe` "max_retries_exceeded"+      entry.resolvedSource `shouldBe` Just fixture.queueName+      entry.originalReadCount `shouldBe` Nothing++    it "returns a full 500-row page with one lookahead row and an exact boundary" $ \fixture -> do+      ids <- mapM (\_ -> seed fixture (envelope Null)) [1 .. 501 :: Int]+      admin <- requireAdmin fixture False+      page <- browseDeadLetters admin fixture.dlqName (DlqPageRequest 500 Nothing) >>= right+      V.length page.items `shouldBe` 500+      page.nextCursor `shouldBe` Just (DlqCursor fixture.dlqName (ids !! 499))+      next <- browseDeadLetters admin fixture.dlqName (DlqPageRequest 500 page.nextCursor) >>= right+      V.map (\item -> item.rawMessage.messageId) next.items `shouldBe` V.singleton (last ids)+      next.nextCursor `shouldBe` Nothing+  administrativePoolSpec++database :: (TestFixture -> IO ()) -> IO ()+database action = withPgmqDb (\pool -> withTestFixture pool action) >>= either (error . show) pure++right :: (Show e) => Either e a -> IO a+right = either (\err -> expectationFailure (show err) >> fail "expected Right") pure++requireAdmin :: TestFixture -> Bool -> IO DeadLetterAdmin+requireAdmin fixture enabled =+  right $+    mkDeadLetterAdmin fixture.pool $+      (defaultDeadLetterAdminConfig [DlqRegistration fixture.dlqName [fixture.queueName]]) {redriveEnabled = enabled}++awaitDlq :: Pool.Pool -> P.QueueName -> IO ()+awaitDlq pool queue = go (500 :: Int)+  where+    go 0 = expectationFailure "handler did not commit its DLQ move"+    go n = do+      rows <- runPgmqSession pool $ S.peekMessages (Q.PeekMessages (P.queueNameToText queue) Nothing 1)+      if V.null rows then threadDelay 10000 >> go (n - 1) else pure ()++envelope :: Value -> Value+envelope body =+  object+    [ "original_message" .= body,+      "dead_letter_reason_code" .= ("orders.validation.failed" :: Text),+      "dead_letter_reason_detail" .= String "detail"+    ]++putField :: Data.Aeson.Key.Key -> Value -> Value -> Value+putField field value (Object fields) = Object (KM.insert field value fields)+putField _ _ _ = error "expected object fixture"++deleteField :: Data.Aeson.Key.Key -> Value -> Value+deleteField field (Object fields) = Object (KM.delete field fields)+deleteField _ _ = error "expected object fixture"++seed :: TestFixture -> Value -> IO P.MessageId+seed fixture body = runPgmqSession fixture.pool $ S.sendMessage (Q.SendMessage fixture.dlqName (P.MessageBody body) Nothing)++seedWithHeaders :: TestFixture -> Value -> Value -> IO P.MessageId+seedWithHeaders fixture body headers =+  runPgmqSession fixture.pool $+    S.sendMessageWithHeaders (Q.SendMessageWithHeaders fixture.dlqName (P.MessageBody body) (P.MessageHeaders headers) Nothing)++snapshot :: Pool.Pool -> P.QueueName -> IO (V.Vector P.Message)+snapshot pool q = runPgmqSession pool $ S.peekMessages (Q.PeekMessages (P.queueNameToText q) Nothing 10000)++namedQueue :: Text -> P.QueueName+namedQueue = either (error . show) id . P.parseQueueName++isDatabaseError :: Either DlqError a -> Bool+isDatabaseError (Left (DatabaseError _)) = True+isDatabaseError _ = False++isRedriven :: Either DlqError RedriveOutcome -> Bool+isRedriven (Right (Redriven _)) = True+isRedriven _ = False++produceFailure :: TestFixture -> PgmqAdapterConfig -> Value -> IO ()+produceFailure fixture config body = do+  void $ runPgmqSession fixture.pool $ S.sendMessage (Q.SendMessage fixture.queueName (P.MessageBody body) Nothing)+  outcome <- runEff $ runErrorNoCallStack @PgmqRuntimeError $ runPgmq fixture.pool $ runTracingNoop $ do+    adapter <- pgmqAdapter (mkPgmqAdapterEnv fixture.pool) config >>= either (error . show) pure+    app <- runApp defaultAppConfig [(ProcessorId "source-identity", mkProcessor adapter (\_ -> pure (AckDeadLetter (InvalidPayload "failed"))))] >>= either (error . show) pure+    liftIO $ awaitDlq fixture.pool fixture.dlqName+    void $ stopAppGracefully defaultShutdownConfig app+  either throwIO pure outcome++administrativePoolSpec :: Spec+administrativePoolSpec = do+  it "scopes statement and lock deadlines to each transaction on a borrowed single-connection pool" $+    withPools $ \fixture adminPool -> do+      admin <- right $ mkDeadLetterAdmin adminPool (defaultDeadLetterAdminConfig [DlqRegistration fixture.dlqName [fixture.queueName]])+      before <- poolSettings adminPool+      messageId <- seed fixture (envelope Null)+      void $ browseDeadLetters admin fixture.dlqName defaultDlqPageRequest >>= right+      void $ fetchDeadLetter admin fixture.dlqName messageId >>= right+      poolSettings adminPool `shouldReturn` before+      rows <- snapshot fixture.pool fixture.dlqName+      let config =+            (defaultDeadLetterAdminConfig [DlqRegistration fixture.dlqName [fixture.queueName]])+              { redriveEnabled = True,+                statementTimeoutMs = 1000,+                lockTimeoutMs = 20+              }+      bounded <- right $ mkDeadLetterAdmin adminPool config+      withLockedQueue fixture.pool fixture.dlqName $ do+        result <- timeout 1000000 (redriveDeadLetter bounded fixture.dlqName messageId)+        result `shouldSatisfy` maybe False isDatabaseError+      poolSettings adminPool `shouldReturn` before+      snapshot fixture.pool fixture.dlqName `shouldReturn` rows+      -- A shorter statement deadline also interrupts read-side lock waits.+      shortStatement <- right $ mkDeadLetterAdmin adminPool (config {statementTimeoutMs = 20, lockTimeoutMs = 1000})+      withLockedQueue fixture.pool fixture.dlqName $ do+        result <- timeout 1000000 (browseDeadLetters shortStatement fixture.dlqName defaultDlqPageRequest)+        result `shouldSatisfy` maybe False isDatabaseError+      poolSettings adminPool `shouldReturn` before+      redriveDeadLetter bounded fixture.dlqName messageId >>= (`shouldSatisfy` isRedriven)+      poolSettings adminPool `shouldReturn` before++  it "lets cancellation escape without a successful absence or a lost DLQ row" $+    withPools $ \fixture adminPool -> do+      admin <-+        right $+          mkDeadLetterAdmin adminPool $+            (defaultDeadLetterAdminConfig [DlqRegistration fixture.dlqName [fixture.queueName]]) {redriveEnabled = True}+      messageId <- seed fixture (envelope (String "cancelled"))+      before <- snapshot fixture.pool fixture.dlqName+      withLockedQueue fixture.pool fixture.dlqName $+        withAsync (redriveDeadLetter admin fixture.dlqName messageId) $ \request -> do+          awaitBlockedRedrive fixture.pool fixture.dlqName+          cancel request+          waitCatch request >>= (`shouldSatisfy` isLeft)+      snapshot fixture.pool fixture.dlqName `shouldReturn` before+      redriveDeadLetter admin fixture.dlqName messageId >>= (`shouldSatisfy` isRedriven)++  it "checks disabled mutations and invalid requests even with an unusable database pool" $ do+    let settings = Settings.connectionString "host=/tmp/plan9-nonexistent-postgres connect_timeout=1"+        config =+          PoolConfig.settings+            [ PoolConfig.size 1,+              PoolConfig.acquisitionTimeout (secondsToDiffTime 1),+              PoolConfig.staticConnectionSettings settings+            ]+        source = namedQueue "source"+        dlq = namedQueue "dlq"+    bracket (Pool.acquire config) Pool.release $ \pool -> do+      admin <- right $ mkDeadLetterAdmin pool (defaultDeadLetterAdminConfig [DlqRegistration dlq [source]])+      redriveDeadLetter admin source (P.MessageId 1) `shouldReturn` Left RedriveDisabled+      browseDeadLetters admin dlq (DlqPageRequest 0 Nothing) `shouldReturn` Left (InvalidPageRequest (InvalidPageLimit 0))+      fetchDeadLetter admin source (P.MessageId 1) `shouldReturn` Left (UnknownManagedDlq source)+      fetchDeadLetter admin dlq (P.MessageId 1) >>= (`shouldSatisfy` isDatabaseError)++withPools :: (TestFixture -> Pool.Pool -> IO ()) -> IO ()+withPools action = withPgmqDbPools 4 1 (\workers admin -> withTestFixture workers (\fixture -> action fixture admin)) >>= either (error . show) pure++poolSettings :: Pool.Pool -> IO (Text, Text)+poolSettings pool =+  runPgmqSession pool $+    Session.statement () $+      Statement.unpreparable+        "select current_setting('statement_timeout'), current_setting('lock_timeout')"+        E.noParams+        (D.singleRow ((,) <$> D.column (D.nonNullable D.text) <*> D.column (D.nonNullable D.text)))++withLockedQueue :: Pool.Pool -> P.QueueName -> IO a -> IO a+withLockedQueue pool q action = do+  ready <- newEmptyMVar+  release <- newEmptyMVar+  let sql statement = Session.statement () (Statement.unpreparable statement E.noParams D.noResult)+      hold = runPgmqSession pool $ do+        sql "begin"+        sql ("lock table pgmq." <> Inspection.quoteIdentifier ("q_" <> P.queueNameToText q) <> " in access exclusive mode")+        liftIO (putMVar ready ())+        liftIO (takeMVar release)+        sql "rollback"+  bracket (async hold) (\worker -> putMVar release () >> void (waitCatch worker)) $ \_ -> do+    acquired <- timeout 5000000 (takeMVar ready)+    acquired `shouldBe` Just ()+    action++-- Wait for the request to be inside its transaction, rather than merely+-- cancelling a thread which might still be acquiring its first connection.+awaitBlockedRedrive :: Pool.Pool -> P.QueueName -> IO ()+awaitBlockedRedrive pool q = go (200 :: Int)+  where+    patternText = "%from pgmq." <> Inspection.quoteIdentifier ("q_" <> P.queueNameToText q) <> "%for update"+    waiting =+      Session.statement patternText $+        Statement.unpreparable+          "select exists(select 1 from pg_stat_activity where wait_event_type = 'Lock' and query like $1)"+          (E.param (E.nonNullable E.text))+          (D.singleRow (D.column (D.nonNullable D.bool)))+    go 0 = expectationFailure "redrive did not reach its row-lock wait"+    go n = do+      blocked <- runPgmqSession pool waiting+      if blocked then pure () else threadDelay 10000 >> go (n - 1)
test/TmpPostgres.hs view
@@ -5,6 +5,7 @@ module TmpPostgres   ( -- * Test Execution     withPgmqDb,+    withPgmqDbPools,     withRestartablePgmqDb,     withTestFixture, @@ -20,6 +21,7 @@ import Control.Monad ((>=>)) import Data.IORef (newIORef, readIORef, writeIORef) import Data.List.NonEmpty (NonEmpty ((:|)))+import Data.Monoid qualified as EphemeralMonoid import Data.Text (Text) import Data.Text qualified as Text import Data.Time (secondsToDiffTime)@@ -35,6 +37,8 @@ import Pgmq.Hasql.Sessions qualified as Pgmq import Pgmq.Migration qualified as Migration import Pgmq.Types (QueueName, parseQueueName)+import System.Directory qualified as EphemeralDirectory+import System.Posix.User qualified as EphemeralUser import System.Random (randomIO)  -- | Test fixture containing pool and queue names for a test@@ -49,7 +53,7 @@ -- This creates an ephemeral PostgreSQL instance, installs the pgmq schema, -- and then runs the provided action with a connection pool. withPgmqDb :: (Pool.Pool -> IO a) -> IO (Either StartError a)-withPgmqDb action = Pg.with $ \db -> do+withPgmqDb action = withEphemeralPg $ \db -> do   let connSettings = Pg.connectionSettings db    -- Install pgmq schema@@ -61,13 +65,21 @@     Pool.release     action +-- | Independent worker and administrative pools against the same disposable DB.+withPgmqDbPools :: Int -> Int -> (Pool.Pool -> Pool.Pool -> IO a) -> IO (Either StartError a)+withPgmqDbPools workerSize adminSize action = withEphemeralPg $ \db -> do+  let settings = Pg.connectionSettings db+  installPgmqSchema settings+  bracket (createPoolWithSize workerSize settings) Pool.release $ \workerPool ->+    bracket (createPoolWithSize adminSize settings) Pool.release (action workerPool)+ -- | Run against an ephemeral database that the test may restart in place. -- The restart preserves the data directory, releases stale pooled connections, -- and lets the next pool use establish a fresh connection to the same socket. withRestartablePgmqDb ::   (Pool.Pool -> IO (Either StartError ()) -> IO a) ->   IO (Either StartError a)-withRestartablePgmqDb action = Pg.with $ \initialDb ->+withRestartablePgmqDb action = withEphemeralPg $ \initialDb ->   bracket     (newIORef initialDb)     (readIORef >=> Pg.stop)@@ -151,13 +163,26 @@  -- | Create a connection pool from connection settings. createPool :: Settings.Settings -> IO Pool.Pool-createPool connSettings = do+createPool = createPoolWithSize 10++createPoolWithSize :: Int -> Settings.Settings -> IO Pool.Pool+createPoolWithSize poolSize connSettings = do   let poolConfig =         PoolConfig.settings-          [ PoolConfig.size 10,+          [ PoolConfig.size poolSize,             PoolConfig.acquisitionTimeout (secondsToDiffTime 10),             PoolConfig.agingTimeout (secondsToDiffTime 3600),             PoolConfig.idlenessTimeout (secondsToDiffTime 60),             PoolConfig.staticConnectionSettings connSettings           ]   Pool.acquire poolConfig++-- | Stable per-user root lets the next invocation reap abandoned clusters.+-- See mori://shinzui/ephemeral-pg/docs/guides (temporary-roots-and-stale-cleanup.md; artifact URI pending).+withEphemeralPg :: (Pg.Database -> IO a) -> IO (Either Pg.StartError a)+withEphemeralPg action = do+  uid <- EphemeralUser.getEffectiveUserID+  let root = "/tmp/ephpg-shibuya-pgmq-adapter-" <> show uid+  EphemeralDirectory.createDirectoryIfMissing True root+  let config = Pg.defaultConfig {Pg.temporaryRoot = EphemeralMonoid.Last (Just root)}+  Pg.withConfig config action