packages feed

shibuya-pgmq-adapter-0.18.0.0: src/Shibuya/Adapter/Pgmq/DeadLetter/Internal.hs

-- | 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)