shibuya-pgmq-adapter-0.11.0.0: src/Shibuya/Adapter/Pgmq/Internal.hs
-- | Internal implementation details for the PGMQ adapter.
-- This module is not part of the public API and may change without notice.
module Shibuya.Adapter.Pgmq.Internal
( -- * Stream Construction
pgmqSource,
pgmqChunks,
pgmqChunksPrefetch,
pgmqMessages,
releaseMessages,
-- * Ingested Construction
mkIngested,
finalizeAutoDeadLetter,
-- * AckHandle Construction
mkAckHandle,
mergeDlqHeaders,
-- * Lease Construction
mkLease,
-- * Query Construction
mkReadMessage,
mkReadWithPoll,
mkReadGrouped,
mkReadGroupedWithPoll,
-- * Utilities
nominalToSeconds,
retryingTransient,
)
where
import Control.Concurrent (threadDelay)
import Control.Monad (void, when)
import Control.Monad.IO.Class (liftIO)
import Data.Aeson (Value (..))
import Data.Aeson.Key qualified as Key
import Data.Aeson.KeyMap qualified as KeyMap
import Data.Foldable (traverse_)
import Data.Function ((&))
import Data.IORef (IORef, newIORef, readIORef, writeIORef)
import Data.Int (Int32)
import Data.Text qualified as Text
import Data.Text.Encoding qualified as TE
import Data.Time (NominalDiffTime, addUTCTime, getCurrentTime, nominalDiffTimeToSeconds)
import Data.Vector (Vector)
import Data.Vector qualified as Vector
import Effectful
( Eff,
IOE,
Limit (..),
Persistence (..),
UnliftStrategy (..),
withUnliftStrategy,
(:>),
)
import Effectful.Error.Static (Error, catchError, throwError)
import Hasql.Pool qualified as Pool
import Hasql.Transaction qualified as Transaction
import Hasql.Transaction.Sessions qualified as Transaction.Sessions
import Pgmq.Effectful (PgmqRuntimeError, fromUsageError, isTransient)
import Pgmq.Effectful.Effect
( Pgmq,
archiveMessage,
batchChangeVisibilityTimeout,
changeVisibilityTimeout,
deleteMessage,
readGrouped,
readGroupedRoundRobin,
readGroupedRoundRobinWithPoll,
readGroupedWithPoll,
readMessage,
readWithPoll,
setVisibilityTimeoutAt,
)
import Pgmq.Hasql.Statements.Message qualified as Msg
import Pgmq.Hasql.Statements.Types
( BatchVisibilityTimeoutQuery (..),
MessageQuery (..),
ReadGrouped (..),
ReadGroupedWithPoll (..),
ReadMessage (..),
ReadWithPollMessage (..),
SendMessage (..),
SendMessageWithHeaders (..),
SendTopic (..),
SendTopicWithHeaders (..),
VisibilityTimeoutAtQuery (..),
VisibilityTimeoutQuery (..),
)
import Pgmq.Types qualified as Pgmq
import Shibuya.Adapter.Pgmq.Config
( DeadLetterConfig (..),
DeadLetterTarget (..),
FifoConfig (..),
FifoReadStrategy (..),
PgmqAdapterConfig (..),
PgmqAdapterEnv (..),
PollRetryConfig (..),
PollingConfig (..),
)
import Shibuya.Adapter.Pgmq.Convert
( mkDlqPayload,
pgmqMessageToEnvelope,
)
import Shibuya.Core.Ack (AckDecision (..), DeadLetterReason (..), RetryDelay (..))
import Shibuya.Core.AckHandle (AckHandle (..))
import Shibuya.Core.Ingested (Ingested (..))
import Shibuya.Core.Lease (Lease (..))
import Shibuya.Core.Types (TraceHeaders)
import Shibuya.Telemetry.Effect (Tracing)
import Shibuya.Telemetry.Propagation (currentTraceHeaders)
import Streamly.Data.Stream (Stream)
import Streamly.Data.Stream qualified as Stream
import Streamly.Data.Stream.Prelude qualified as StreamP
import Streamly.Data.Unfold qualified as Unfold
-- | Convert 'NominalDiffTime' to seconds as 'Int32', saturating at the
-- 'Int32' bounds.
--
-- Used when extending pgmq visibility timeouts ('AckRetry', 'AckHalt', and
-- lease extension). pgmq's @changeVisibilityTimeout@ accepts 'Int32'
-- seconds; values larger than @maxBound@ (~68 years) silently wrap under
-- the previous @ceiling . nominalDiffTimeToSeconds@ implementation. This
-- helper saturates instead, so a misconfigured 'BackoffPolicy.maxDelay'
-- produces a merely-very-long retry rather than a corrupt or
-- panic-inducing one.
nominalToSeconds :: NominalDiffTime -> Int32
nominalToSeconds dt =
let seconds :: Double
seconds = realToFrac (nominalDiffTimeToSeconds dt)
maxSec :: Double
maxSec = fromIntegral (maxBound :: Int32)
minSec :: Double
minSec = fromIntegral (minBound :: Int32)
clamped = max minSec (min maxSec seconds)
in ceiling clamped
-- | Create a ReadMessage query from config.
mkReadMessage :: PgmqAdapterConfig -> ReadMessage
mkReadMessage config =
ReadMessage
{ queueName = config.queueName,
delay = config.visibilityTimeout,
batchSize = Just config.batchSize,
conditional = Nothing
}
-- | Create a ReadWithPollMessage query from config.
mkReadWithPoll :: PgmqAdapterConfig -> Int32 -> Int32 -> ReadWithPollMessage
mkReadWithPoll config maxSec intervalMs =
ReadWithPollMessage
{ queueName = config.queueName,
delay = config.visibilityTimeout,
batchSize = Just config.batchSize,
maxPollSeconds = maxSec,
pollIntervalMs = intervalMs,
conditional = Nothing
}
-- | Create a ReadGrouped query from config.
mkReadGrouped :: PgmqAdapterConfig -> ReadGrouped
mkReadGrouped config =
ReadGrouped
{ queueName = config.queueName,
visibilityTimeout = config.visibilityTimeout,
qty = config.batchSize
}
-- | Create a ReadGroupedWithPoll query from config.
mkReadGroupedWithPoll :: PgmqAdapterConfig -> Int32 -> Int32 -> ReadGroupedWithPoll
mkReadGroupedWithPoll config maxSec intervalMs =
ReadGroupedWithPoll
{ queueName = config.queueName,
visibilityTimeout = config.visibilityTimeout,
qty = config.batchSize,
maxPollSeconds = maxSec,
pollIntervalMs = intervalMs
}
-- | Create a Lease for visibility timeout extension.
mkLease ::
(Pgmq :> es, Error PgmqRuntimeError :> es, IOE :> es) =>
PgmqAdapterConfig ->
Pgmq.Message ->
Eff es (Lease es)
mkLease config msg = do
lastVtRef <- liftIO $ newIORef msg.visibilityTime
pure
Lease
{ leaseId = Text.pack (show (Pgmq.unMessageId msg.messageId)),
leaseExtend = \duration -> do
now <- liftIO getCurrentTime
lastVt <- liftIO $ readIORef lastVtRef
let target = max lastVt (addUTCTime duration now)
updated <-
retryingTransient config.ackRetry $
setVisibilityTimeoutAt $
VisibilityTimeoutAtQuery
{ queueName = config.queueName,
messageId = msg.messageId,
visibilityTime = target
}
liftIO $ writeIORef lastVtRef updated.visibilityTime
}
-- | Create an AckHandle for a message.
--
-- The 'AckDeadLetter' branch threads the *consumer's* current trace
-- context (looked up via 'currentTraceHeaders' against the active OTel
-- span) into the DLQ message's headers. The original producer's
-- @traceparent@/@tracestate@ are preserved under the
-- @x-shibuya-upstream-traceparent@ / @x-shibuya-upstream-tracestate@
-- keys so a DLQ post-mortem can walk back to the origin if it wants.
-- When tracing is disabled (or there is no active span at the call
-- site), the original headers are forwarded verbatim — exactly the
-- pre-0.5.0.0 behavior. See plan 1 / Finding F3 in the parent
-- shibuya repo's plan 9.
mkAckHandle ::
(Pgmq :> es, Error PgmqRuntimeError :> es, IOE :> es, Tracing :> es) =>
PgmqAdapterEnv ->
PgmqAdapterConfig ->
IORef Bool ->
Pgmq.Message ->
AckHandle es
mkAckHandle env config finalizedRef msg = AckHandle $ \decision -> do
alreadyFinalized <- liftIO $ readIORef finalizedRef
if alreadyFinalized
then pure ()
else do
runDecision decision
liftIO $ writeIORef finalizedRef True
where
queueName = config.queueName
msgId = msg.messageId
runDecision decision = retryingTransient config.ackRetry $ case decision of
AckOk -> do
-- Successfully processed - delete from queue
void $ deleteMessage (MessageQuery queueName msgId)
AckRetry (RetryDelay delay) -> do
-- Retry after delay - extend visibility timeout
let vtSeconds = nominalToSeconds delay
void $
changeVisibilityTimeout $
VisibilityTimeoutQuery
{ queueName = queueName,
messageId = msgId,
visibilityTimeoutOffset = vtSeconds
}
AckDeadLetter reason -> do
-- Handle dead-lettering
case config.deadLetterConfig of
Nothing ->
-- No DLQ configured - just archive the message
void $ archiveMessage (MessageQuery queueName msgId)
Just dlqConfig -> do
consumerHdrs <- currentTraceHeaders
deadLetterTransactionally env config dlqConfig msg reason (mergeDlqHeaders consumerHdrs msg.headers)
AckHalt _reason -> do
-- Park the message by reassigning its visibility timeout. It becomes
-- visible to any consumer after this many seconds, independent of restart.
let vtSeconds = maybe config.visibilityTimeout id config.haltVisibilityTimeout
void $
changeVisibilityTimeout $
VisibilityTimeoutQuery
{ queueName = queueName,
messageId = msgId,
visibilityTimeoutOffset = vtSeconds
}
deadLetterTransactionally ::
(Error PgmqRuntimeError :> es, IOE :> es) =>
PgmqAdapterEnv ->
PgmqAdapterConfig ->
DeadLetterConfig ->
Pgmq.Message ->
DeadLetterReason ->
Maybe Value ->
Eff es ()
deadLetterTransactionally env config dlqConfig msg reason dlqHeaders = do
result <- liftIO $ Pool.use env.pool session
case result of
Left err -> throwError (fromUsageError err)
Right () -> pure ()
where
dlqBody = mkDlqPayload msg reason dlqConfig.includeMetadata
sourceQuery = MessageQuery config.queueName msg.messageId
session =
Transaction.Sessions.transaction
Transaction.Sessions.ReadCommitted
Transaction.Sessions.Write
tx
tx = do
case dlqConfig.dlqTarget of
DirectQueue dlqQueueName ->
case dlqHeaders of
Just headers ->
void $
Transaction.statement
SendMessageWithHeaders
{ queueName = dlqQueueName,
messageBody = dlqBody,
messageHeaders = Pgmq.MessageHeaders headers,
delay = Nothing
}
Msg.sendMessageWithHeaders
Nothing ->
void $
Transaction.statement
SendMessage
{ queueName = dlqQueueName,
messageBody = dlqBody,
delay = Nothing
}
Msg.sendMessage
TopicRoute routingKey ->
case dlqHeaders of
Just headers ->
void $
Transaction.statement
SendTopicWithHeaders
{ routingKey = routingKey,
messageBody = dlqBody,
messageHeaders = Pgmq.MessageHeaders headers,
delay = Nothing
}
Msg.sendTopicWithHeaders
Nothing ->
void $
Transaction.statement
SendTopic
{ routingKey = routingKey,
messageBody = dlqBody,
delay = Nothing
}
Msg.sendTopic
void $ Transaction.statement sourceQuery Msg.deleteMessage
-- | Merge the consumer's current trace headers with the original
-- message's headers JSON for the DLQ-write path.
--
-- Rules:
--
-- * If the consumer has no active span (tracing disabled, or
-- producer-side path runs outside any 'withSpan'), forward the
-- original headers verbatim — matches the pre-0.5.0.0 behavior.
-- * Otherwise, the consumer's @traceparent@ overrides the original's
-- active @traceparent@; the original's @traceparent@ /
-- @tracestate@ (if present) move to
-- @x-shibuya-upstream-traceparent@ / @x-shibuya-upstream-tracestate@.
--
-- Returns 'Nothing' only if both inputs are empty (no consumer
-- context AND no original headers); in that case the caller falls
-- through to the no-headers @sendMessage@/@sendTopic@ path.
mergeDlqHeaders :: Maybe TraceHeaders -> Maybe Value -> Maybe Value
mergeDlqHeaders Nothing originalHeaders = originalHeaders
mergeDlqHeaders (Just consumerHdrs) originalHeaders =
let originalObj = case originalHeaders of
Just (Object obj) -> obj
_ -> KeyMap.empty
stashedUpstream = stashUpstreamTrace originalObj
consumerEntries = traceHeadersToKeyMap consumerHdrs
merged = stashedUpstream <> consumerEntries
in if KeyMap.null merged
then Nothing
else Just (Object merged)
where
-- Move any active @traceparent@/@tracestate@ on the original
-- headers under the @x-shibuya-upstream-*@ prefix so the
-- consumer's value can take the active slot. Other keys pass
-- through unchanged.
stashUpstreamTrace obj =
foldr
(uncurry (rename obj))
(KeyMap.delete "traceparent" (KeyMap.delete "tracestate" obj))
[ ("traceparent", "x-shibuya-upstream-traceparent"),
("tracestate", "x-shibuya-upstream-tracestate")
]
rename src srcKey dstKey acc =
case KeyMap.lookup (Key.fromText srcKey) src of
Just v -> KeyMap.insert (Key.fromText dstKey) v acc
Nothing -> acc
-- Convert TraceHeaders ([(ByteString, ByteString)]) to a JSON object.
traceHeadersToKeyMap hdrs =
KeyMap.fromList
[ (Key.fromText (TE.decodeUtf8Lenient k), String (TE.decodeUtf8Lenient v))
| (k, v) <- hdrs
]
-- | Create an Ingested from a pgmq Message.
-- Handles auto dead-lettering when maxRetries is exceeded.
mkIngested ::
(Pgmq :> es, Error PgmqRuntimeError :> es, IOE :> es, Tracing :> es) =>
PgmqAdapterEnv ->
PgmqAdapterConfig ->
Pgmq.Message ->
Eff es (Maybe (Ingested es Value))
mkIngested env config msg = do
finalizedRef <- liftIO $ newIORef False
lease <- mkLease config msg
let ackHandle = mkAckHandle env config finalizedRef msg
-- Check if max retries exceeded
if msg.readCount > config.maxRetries
then do
-- Auto dead-letter messages that exceed retry limit
finalizeAutoDeadLetter
msg
env.onAutoDeadLetter
env.onAckFailure
(ackHandle.finalize (AckDeadLetter MaxRetriesExceeded))
-- Return Nothing - this message won't be processed by handler
pure Nothing
else
pure $
Just
Ingested
{ envelope = pgmqMessageToEnvelope msg,
ack = ackHandle,
lease = Just lease
}
finalizeAutoDeadLetter ::
(Error PgmqRuntimeError :> es, IOE :> es) =>
Pgmq.Message ->
(Pgmq.Message -> IO ()) ->
(Pgmq.Message -> PgmqRuntimeError -> IO ()) ->
Eff es () ->
Eff es ()
finalizeAutoDeadLetter msg onAutoDeadLetter onAckFailure finalizeAction =
(finalizeAction >> liftIO (onAutoDeadLetter msg))
`catchError` \_callStack err -> liftIO (onAckFailure msg err)
-- | Stream of message batches from pgmq.
-- Each element is a Vector of messages from a single poll.
-- This is the lowest-level stream that handles polling logic.
pgmqChunks ::
(Pgmq :> es, Error PgmqRuntimeError :> es, IOE :> es) =>
PgmqAdapterConfig ->
Stream (Eff es) (Vector Pgmq.Message)
pgmqChunks config = Stream.repeatM (retryingTransient config.pollRetry poll)
where
poll :: (Pgmq :> es, IOE :> es) => Eff es (Vector Pgmq.Message)
poll = case config.fifoConfig of
Nothing -> pollNonFifo
Just fifo -> pollFifo fifo
pollNonFifo :: (Pgmq :> es, IOE :> es) => Eff es (Vector Pgmq.Message)
pollNonFifo = case config.polling of
StandardPolling interval -> do
result <- readMessage (mkReadMessage config)
when (Vector.null result) $
liftIO $
threadDelay (nominalToMicros interval)
pure result
LongPolling maxSec intervalMs ->
readWithPoll (mkReadWithPoll config maxSec intervalMs)
pollFifo :: (Pgmq :> es, IOE :> es) => FifoConfig -> Eff es (Vector Pgmq.Message)
pollFifo fifo = case config.polling of
StandardPolling interval -> do
result <- case fifo.readStrategy of
ThroughputOptimized -> readGrouped (mkReadGrouped config)
RoundRobin -> readGroupedRoundRobin (mkReadGrouped config)
when (Vector.null result) $
liftIO $
threadDelay (nominalToMicros interval)
pure result
LongPolling maxSec intervalMs ->
case fifo.readStrategy of
ThroughputOptimized ->
readGroupedWithPoll (mkReadGroupedWithPoll config maxSec intervalMs)
RoundRobin ->
readGroupedRoundRobinWithPoll (mkReadGroupedWithPoll config maxSec intervalMs)
nominalToMicros :: NominalDiffTime -> Int
nominalToMicros t = floor (nominalDiffTimeToSeconds t * 1_000_000)
-- | Chunk polling with concurrent prefetch.
--
-- Wraps 'pgmqChunks' in streamly's @parBuffered@ so the next batches are polled
-- on a background worker while the current messages are processed. @parBuffered@
-- forks worker threads that must unlift @Eff es@ to @IO@; effectful's default
-- 'SeqUnlift' strategy throws when its unlift runs off-thread, which is the
-- historical prefetch deadlock. We therefore run the concurrent portion under
-- 'ConcUnlift' (which clones the effect environment per worker thread), scoped
-- locally with 'withUnliftStrategy' via 'Stream.morphInner' so the override is
-- in force at the moment @parBuffered@ forks — and so it does /not/ leak to the
-- non-prefetch path, which keeps running under 'SeqUnlift'.
--
-- 'Stream.morphInner' is applied /after/ @parBuffered@ so it wraps @parBuffered@'s
-- own forking step. Scoping the strategy at stream-construction time instead
-- would not work: the fork happens when the stream is run, not when it is built.
pgmqChunksPrefetch ::
(Pgmq :> es, Error PgmqRuntimeError :> es, IOE :> es) =>
(StreamP.Config -> StreamP.Config) ->
PgmqAdapterConfig ->
Stream (Eff es) (Vector Pgmq.Message)
pgmqChunksPrefetch prefetchSettings config =
pgmqChunks config
& StreamP.parBuffered prefetchSettings
& Stream.morphInner (withUnliftStrategy (ConcUnlift Ephemeral Unlimited))
retryingTransient ::
(Error PgmqRuntimeError :> es, IOE :> es) =>
PollRetryConfig ->
Eff es a ->
Eff es a
retryingTransient retry action = go 1 retry.initialBackoff
where
go attempt backoff =
action `catchError` \_callStack err ->
if isTransient err && attempt < retry.maxAttempts
then do
liftIO $ threadDelay (nominalToMicros backoff)
go (attempt + 1) (min (backoff * 2) retry.maxBackoff)
else throwError err
nominalToMicros :: NominalDiffTime -> Int
nominalToMicros t = floor (nominalDiffTimeToSeconds t * 1_000_000)
-- | Flatten message chunks into individual messages.
-- Uses Streamly's unfoldEach to expand each Vector into individual elements,
-- ensuring ALL messages from each batch are processed (not just the first).
pgmqMessages ::
(Pgmq :> es, Error PgmqRuntimeError :> es, IOE :> es) =>
PgmqAdapterConfig ->
Stream (Eff es) Pgmq.Message
pgmqMessages config =
pgmqChunks config
& Stream.filter (not . Vector.null) -- Skip empty batches
& Stream.unfoldEach vectorUnfold -- Flatten Vector to individual elements
where
-- Unfold a Vector into a stream of elements using uncons
vectorUnfold = Unfold.unfoldr Vector.uncons
-- | Create the message source stream.
-- This stream polls pgmq and yields Ingested messages.
-- Uses unfoldEach to process ALL messages from each batch, not just the first.
pgmqSource ::
(Pgmq :> es, Error PgmqRuntimeError :> es, IOE :> es, Tracing :> es) =>
PgmqAdapterEnv ->
PgmqAdapterConfig ->
Stream (Eff es) (Ingested es Value)
pgmqSource env config =
pgmqMessages config
& Stream.mapMaybeM (mkIngested env config) -- Convert + filter auto-DLQ'd messages
releaseMessages ::
(Pgmq :> es, Error PgmqRuntimeError :> es, IOE :> es) =>
PgmqAdapterEnv ->
PgmqAdapterConfig ->
Vector Pgmq.Message ->
Eff es ()
releaseMessages env config messages = do
let msgIds = fmap (.messageId) (Vector.toList messages)
retryingTransient
config.ackRetry
( void $
batchChangeVisibilityTimeout $
BatchVisibilityTimeoutQuery
{ queueName = config.queueName,
messageIds = msgIds,
visibilityTimeoutOffset = 0
}
)
`catchError` \_callStack err ->
liftIO $ traverse_ (\message -> env.onAckFailure message err) messages