shibuya-core-0.9.0.0: src/Shibuya/Core/Ack.hs
-- | Ack semantics for message processing.
-- Handlers decide meaning, not mechanics.
-- This is explicit to support halt-on-error for ordered streams.
module Shibuya.Core.Ack
( -- * Retry
RetryDelay (..),
-- * Dead Letter
DeadLetterCode,
mkDeadLetterCode,
deadLetterCodeText,
DeadLetterReason (..),
deadLetterReasonCode,
deadLetterReasonDetail,
renderDeadLetterReason,
-- * Halt
HaltReason (..),
-- * Handler Decision
AckDecision (..),
)
where
import Data.Char (isAsciiLower)
import Data.Text qualified as Text
import Shibuya.Prelude
-- | Delay before retry.
newtype RetryDelay = RetryDelay {unRetryDelay :: NominalDiffTime}
deriving stock (Eq, Show)
-- | A stable, machine-queryable application dead-letter identifier.
--
-- The constructor is intentionally private. Validate a finite set of codes
-- during application startup, retain the resulting values in configuration,
-- and reuse them in handlers rather than validating on every message.
newtype DeadLetterCode = DeadLetterCode Text
deriving stock (Eq, Ord, Show)
-- | Validate an application-owned dead-letter code.
--
-- A valid code is at most 128 ASCII characters and contains at least two
-- dot-separated segments. Each segment starts with a lowercase ASCII letter
-- and then contains only lowercase ASCII letters, digits, or underscores. The
-- first segment @shibuya@ is reserved for framework-owned codes.
mkDeadLetterCode :: Text -> Either Text DeadLetterCode
mkDeadLetterCode code
| Text.null code = invalid "must not be empty"
| Text.length (Text.take 129 code) > 128 = invalid "must contain at most 128 ASCII characters"
| length segments < 2 = invalid "must contain at least two dot-separated segments"
| Just segment <- firstInvalidSegment segments =
invalid $ Text.concat ["segment \"", segment, "\" must match [a-z][a-z0-9_]*"]
| hasReservedFirstSegment segments = invalid "must not use the reserved first segment \"shibuya\""
| otherwise = Right (DeadLetterCode code)
where
segments = Text.splitOn "." code
invalid rule = Left $ Text.concat ["invalid dead-letter code \"", code, "\": ", rule]
-- | Unwrap a validated dead-letter code for storage, tracing, or logging.
deadLetterCodeText :: DeadLetterCode -> Text
deadLetterCodeText (DeadLetterCode code) = code
-- | Why a message is being dead-lettered.
data DeadLetterReason
= -- | The message is permanently unprocessable, despite retries.
PoisonPill !Text
| -- | The message payload failed parsing or structural validation.
InvalidPayload !Text
| -- | The framework's retry limit was exceeded.
MaxRetriesExceeded
| -- | A syntactically valid message was permanently rejected by
-- application policy.
--
-- The application owns the stability of the code. Detail is transported
-- verbatim for operators and must not contain secrets, unrestricted
-- backend errors, raw SQL, or full payloads.
ApplicationFailure !DeadLetterCode !Text
deriving stock (Eq, Show, Generic)
-- | Return the stable machine-facing code for any dead-letter reason.
deadLetterReasonCode :: DeadLetterReason -> DeadLetterCode
deadLetterReasonCode (PoisonPill _) = poisonPillCode
deadLetterReasonCode (InvalidPayload _) = invalidPayloadCode
deadLetterReasonCode MaxRetriesExceeded = maxRetriesExceededCode
deadLetterReasonCode (ApplicationFailure code _) = code
-- | Return human-facing detail when the reason carries it.
--
-- Detail is transported verbatim. Applications must keep it operationally
-- bounded and exclude secrets, raw payloads, raw SQL, and unrestricted
-- backend error text.
deadLetterReasonDetail :: DeadLetterReason -> Maybe Text
deadLetterReasonDetail (PoisonPill detail) = Just detail
deadLetterReasonDetail (InvalidPayload detail) = Just detail
deadLetterReasonDetail MaxRetriesExceeded = Nothing
deadLetterReasonDetail (ApplicationFailure _ detail) = Just detail
-- | Render a reason in Shibuya's canonical compatibility format.
--
-- Built-in strings retain their historical encoding. Adapters with
-- structured storage should prefer 'deadLetterReasonCode' and
-- 'deadLetterReasonDetail' separately.
renderDeadLetterReason :: DeadLetterReason -> Text
renderDeadLetterReason reason =
let code = deadLetterCodeText (deadLetterReasonCode reason)
in case deadLetterReasonDetail reason of
Nothing -> code
Just detail -> Text.concat [code, ": ", detail]
firstInvalidSegment :: [Text] -> Maybe Text
firstInvalidSegment [] = Nothing
firstInvalidSegment (segment : rest)
| validSegment segment = firstInvalidSegment rest
| otherwise = Just segment
validSegment :: Text -> Bool
validSegment segment =
case Text.uncons segment of
Nothing -> False
Just (first, suffix) ->
isAsciiLower first && Text.all validSegmentSuffix suffix
validSegmentSuffix :: Char -> Bool
validSegmentSuffix char =
isAsciiLower char || ('0' <= char && char <= '9') || char == '_'
hasReservedFirstSegment :: [Text] -> Bool
hasReservedFirstSegment (first : _) = first == "shibuya"
hasReservedFirstSegment [] = False
poisonPillCode :: DeadLetterCode
poisonPillCode = DeadLetterCode "poison_pill"
invalidPayloadCode :: DeadLetterCode
invalidPayloadCode = DeadLetterCode "invalid_payload"
maxRetriesExceededCode :: DeadLetterCode
maxRetriesExceededCode = DeadLetterCode "max_retries_exceeded"
-- | Why processing should halt.
data HaltReason
= -- | Must stop to preserve ordering guarantees
HaltOrderedStream !Text
| -- | Unrecoverable error
HaltFatal !Text
deriving stock (Eq, Show, Generic)
-- | Handler outcome (semantic, not mechanical).
-- The handler returns this to express intent; the framework handles the mechanics.
data AckDecision
= -- | Message processed successfully
AckOk
| -- | Retry after delay
AckRetry !RetryDelay
| -- | Move to dead letter queue
AckDeadLetter !DeadLetterReason
| -- | Stop processing
AckHalt !HaltReason
deriving stock (Eq, Show, Generic)