packages feed

keiro-0.6.0.0: src/Keiro/Inbox/Schema.hs

{-# LANGUAGE ApplicativeDo #-}

-- | Hasql-level storage for the idempotent integration-event inbox.
--
-- This module owns the SQL surface that the inbox wrapper
-- ('Keiro.Inbox.runInboxTransaction') and the optional Kafka decoder
-- ('Keiro.Inbox.Kafka') consume.
module Keiro.Inbox.Schema
  ( tryInsertCompletedTx,
    markCompletedTx,
    markFailedTx,
    recordFailedAttemptTx,
    lookupInbox,
    listInbox,
    garbageCollectCompleted,
    countInboxBacklog,
  )
where

import Contravariant.Extras (contrazip2, contrazip3, contrazip4)
import Data.ByteString (ByteString)
import Data.Functor.Contravariant ((>$<))
import Data.Time.Clock (NominalDiffTime, addUTCTime)
import Data.Time.Clock.POSIX (posixSecondsToUTCTime)
import Data.UUID (UUID)
import Effectful (Eff, (:>))
import Hasql.Decoders qualified as D
import Hasql.Encoders qualified as E
import Hasql.Statement (Statement, preparable)
import Keiro.Inbox.Types
import Keiro.Integration.Event
  ( IntegrationEvent (..),
    SchemaReference (..),
    TraceContext (..),
    contentTypeText,
    parseContentType,
  )
import Keiro.Prelude
import Kiroku.Store.Effect (Store)
import Kiroku.Store.Transaction (runTransaction)
import Kiroku.Store.Types (EventId (..), GlobalPosition (..))
import "hasql-transaction" Hasql.Transaction qualified as Tx

-- | Attempt to insert a new completed row.
--
-- Returns 'Left' carrying the existing row if @(source, dedupe_key)@
-- already exists; returns 'Right ()' if the insert created a new row. The
-- caller — typically 'Keiro.Inbox.runInboxTransaction' — then either runs
-- the handler (new) or branches on the existing row's status (duplicate).
--
-- The row is inserted as @completed@ before the handler runs, but remains
-- uncommitted until the handler transaction succeeds. On handler failure the
-- whole transaction rolls back, so the completed row never becomes visible.
--
-- If a concurrent retention job deletes the conflicting row between the
-- insert attempt and the lookup, this returns 'Right ()'. The handler's effects
-- then commit without a replacement deduplication row, and a later redelivery
-- can run the handler again. This preserves at-least-once delivery but not
-- permanent exactly-once processing. Retention must exceed the maximum tolerated
-- redelivery delay, and handlers must remain idempotent.
tryInsertCompletedTx ::
  InboxPersistence ->
  Text ->
  Text ->
  IntegrationEvent ->
  Maybe KafkaDeliveryRef ->
  UTCTime ->
  Tx.Transaction (Either InboxRow ())
tryInsertCompletedTx persistence src dedupe event kafka now = do
  inserted <-
    Tx.statement (toEncodedInsert persistence src dedupe event kafka now) tryInsertStmt
  if inserted
    then pure (Right ())
    else do
      existing <- Tx.statement (src, dedupe) selectByKeyStmt
      case existing of
        Just row -> pure (Left row)
        Nothing -> pure (Right ())

-- | Mark an inbox row completed inside the same transaction as the handler.
markCompletedTx :: Text -> Text -> UTCTime -> Tx.Transaction ()
markCompletedTx src dedupe now =
  Tx.statement (src, dedupe, now) markCompletedStmt

-- | Mark an inbox row failed inside the same transaction as the handler.
markFailedTx :: Text -> Text -> Text -> UTCTime -> Tx.Transaction ()
markFailedTx src dedupe errMsg now =
  Tx.statement (src, dedupe, errMsg, now) markFailedStmt

-- | Record one failed handler attempt for @(source, dedupe_key)@.
--
-- Creates a failed row when the handler transaction rolled back the initial
-- processing insert, or increments the existing failed row's attempt count.
-- Returns the new attempt count.
recordFailedAttemptTx ::
  Text ->
  Text ->
  IntegrationEvent ->
  Maybe KafkaDeliveryRef ->
  Text ->
  UTCTime ->
  Tx.Transaction Int
recordFailedAttemptTx src dedupe event kafka errMsg now =
  Tx.statement (toEncodedFailedInsert src dedupe event kafka errMsg now) recordFailedAttemptStmt

-- | Read one inbox row.
lookupInbox :: (Store :> es) => Text -> Text -> Eff es (Maybe InboxRow)
lookupInbox src dedupe =
  runTransaction $
    Tx.statement (src, dedupe) selectByKeyStmt

-- | List inbox rows for a source, ordered by @received_at@. Test helper.
listInbox :: (Store :> es) => Text -> Eff es [InboxRow]
listInbox src =
  runTransaction $
    Tx.statement src listBySourceStmt

-- | Count inbox rows in a non-terminal state (backlog gauge source).
--
-- Backlog = rows still @processing@ (in flight) or @failed@ (awaiting a
-- retry decision). Completed rows are terminal and excluded.
countInboxBacklog :: (Store :> es) => Eff es Int
countInboxBacklog =
  runTransaction (Tx.statement () countInboxBacklogStmt)

-- | Delete completed inbox rows older than @keepFor@ from @now@.
--
-- Returns the number of rows deleted. The retention window defines the
-- duplicate-detection window: a redelivery that arrives after retention
-- GC has run will be processed again, so the window must exceed the
-- maximum delivery delay tolerated by the operator. The user guide
-- recommends 30 days as a default. See 'tryInsertCompletedTx' for the related
-- concurrent-GC race and its at-least-once consequence.
garbageCollectCompleted ::
  (Store :> es) =>
  NominalDiffTime ->
  UTCTime ->
  Eff es Int
garbageCollectCompleted keepFor now = do
  let cutoff = addUTCTime (negate keepFor) now
  result <-
    runTransaction $
      Tx.statement cutoff gcStmt
  pure (fromIntegral result)

-- ---------------------------------------------------------------------------
-- Encoder support
-- ---------------------------------------------------------------------------

data EncodedInsert = EncodedInsert
  { source :: !Text,
    dedupeKey :: !Text,
    messageId :: !(Maybe Text),
    sourceEventId :: !(Maybe UUID),
    sourceGlobalPosition :: !(Maybe Int64),
    destination :: !(Maybe Text),
    eventType :: !(Maybe Text),
    schemaVersion :: !(Maybe Int64),
    contentType :: !Text,
    schemaRegistry :: !(Maybe Text),
    schemaSubject :: !(Maybe Text),
    schemaVersionRef :: !(Maybe Int64),
    schemaId :: !(Maybe Int64),
    schemaFingerprint :: !(Maybe Text),
    causationId :: !(Maybe UUID),
    correlationId :: !(Maybe UUID),
    traceparent :: !(Maybe Text),
    tracestate :: !(Maybe Text),
    kafkaTopic :: !(Maybe Text),
    kafkaPartition :: !(Maybe Int64),
    kafkaOffset :: !(Maybe Int64),
    payloadBytes :: !ByteString,
    attributes :: !(Maybe Value),
    occurredAt :: !(Maybe UTCTime),
    receivedAt :: !UTCTime
  }
  deriving stock (Generic)

data EncodedFailedInsert = EncodedFailedInsert
  { insert :: !EncodedInsert,
    lastError :: !Text
  }
  deriving stock (Generic)

toEncodedInsert ::
  InboxPersistence ->
  Text ->
  Text ->
  IntegrationEvent ->
  Maybe KafkaDeliveryRef ->
  UTCTime ->
  EncodedInsert
toEncodedInsert persistence src dedupe event kafka now =
  let mref = event ^. #schemaReference
      mtrace = event ^. #traceContext
   in EncodedInsert
        { source = src,
          dedupeKey = dedupe,
          messageId = nullIfEmpty (event ^. #messageId),
          sourceEventId = fmap unEventId (event ^. #sourceEventId),
          sourceGlobalPosition = fmap unGlobalPosition (event ^. #sourceGlobalPosition),
          destination = nullIfEmpty (event ^. #destination),
          eventType = nullIfEmpty (event ^. #eventType),
          schemaVersion = persistedSchema (Just (fromIntegral (event ^. #schemaVersion))),
          contentType = contentTypeText (event ^. #contentType),
          schemaRegistry = persistedSchema (mref >>= (^. #registry)),
          schemaSubject = persistedSchema (mref >>= (^. #subject)),
          schemaVersionRef = persistedSchema (fmap fromIntegral (mref >>= (^. #version))),
          schemaId = persistedSchema (mref >>= (^. #schemaId)),
          schemaFingerprint = persistedSchema (mref >>= (^. #fingerprint)),
          causationId = fmap unEventId (event ^. #causationId),
          correlationId = fmap unEventId (event ^. #correlationId),
          traceparent = persistedEnvelope (fmap (^. #traceparent) mtrace),
          tracestate = persistedEnvelope (mtrace >>= (^. #tracestate)),
          kafkaTopic = fmap (^. #topic) kafka,
          kafkaPartition = fmap (^. #partition) kafka,
          kafkaOffset = fmap (^. #offset) kafka,
          payloadBytes = case persistence of
            PersistFullEnvelope -> event ^. #payloadBytes
            PersistDedupeOnly -> mempty,
          attributes = persistedEnvelope (event ^. #attributes),
          occurredAt = Just (event ^. #occurredAt),
          receivedAt = now
        }
  where
    persistedEnvelope :: Maybe x -> Maybe x
    persistedEnvelope value = case persistence of
      PersistFullEnvelope -> value
      PersistDedupeOnly -> Nothing

    persistedSchema :: Maybe x -> Maybe x
    persistedSchema = persistedEnvelope

toEncodedFailedInsert ::
  Text ->
  Text ->
  IntegrationEvent ->
  Maybe KafkaDeliveryRef ->
  Text ->
  UTCTime ->
  EncodedFailedInsert
toEncodedFailedInsert src dedupe event kafka errMsg now =
  EncodedFailedInsert
    { insert = toEncodedInsert PersistFullEnvelope src dedupe event kafka now,
      lastError = errMsg
    }

nullIfEmpty :: Text -> Maybe Text
nullIfEmpty t = if t == mempty then Nothing else Just t

unEventId :: EventId -> UUID
unEventId (EventId u) = u

unGlobalPosition :: GlobalPosition -> Int64
unGlobalPosition (GlobalPosition i) = i

encodedInsertEncoder :: E.Params EncodedInsert
encodedInsertEncoder =
  mconcat
    [ view #source >$< E.param (E.nonNullable E.text),
      view #dedupeKey >$< E.param (E.nonNullable E.text),
      view #messageId >$< E.param (E.nullable E.text),
      view #sourceEventId >$< E.param (E.nullable E.uuid),
      view #sourceGlobalPosition >$< E.param (E.nullable E.int8),
      view #destination >$< E.param (E.nullable E.text),
      view #eventType >$< E.param (E.nullable E.text),
      view #schemaVersion >$< E.param (E.nullable E.int8),
      view #contentType >$< E.param (E.nonNullable E.text),
      view #schemaRegistry >$< E.param (E.nullable E.text),
      view #schemaSubject >$< E.param (E.nullable E.text),
      view #schemaVersionRef >$< E.param (E.nullable E.int8),
      view #schemaId >$< E.param (E.nullable E.int8),
      view #schemaFingerprint >$< E.param (E.nullable E.text),
      view #causationId >$< E.param (E.nullable E.uuid),
      view #correlationId >$< E.param (E.nullable E.uuid),
      view #traceparent >$< E.param (E.nullable E.text),
      view #tracestate >$< E.param (E.nullable E.text),
      view #kafkaTopic >$< E.param (E.nullable E.text),
      view #kafkaPartition >$< E.param (E.nullable E.int8),
      view #kafkaOffset >$< E.param (E.nullable E.int8),
      view #payloadBytes >$< E.param (E.nonNullable E.bytea),
      view #attributes >$< E.param (E.nullable E.jsonb),
      view #occurredAt >$< E.param (E.nullable E.timestamptz),
      view #receivedAt >$< E.param (E.nonNullable E.timestamptz)
    ]

encodedFailedInsertEncoder :: E.Params EncodedFailedInsert
encodedFailedInsertEncoder =
  (view #insert >$< encodedInsertEncoder)
    <> (view #lastError >$< E.param (E.nonNullable E.text))

-- ---------------------------------------------------------------------------
-- Statements
-- ---------------------------------------------------------------------------

tryInsertStmt :: Statement EncodedInsert Bool
tryInsertStmt =
  preparable
    """
    INSERT INTO keiro.keiro_inbox
      ( source
      , dedupe_key
      , message_id
      , source_event_id
      , source_global_position
      , destination
      , event_type
      , schema_version
      , content_type
      , schema_registry
      , schema_subject
      , schema_version_ref
      , schema_id
      , schema_fingerprint
      , causation_id
      , correlation_id
      , traceparent
      , tracestate
      , kafka_topic
      , kafka_partition
      , kafka_offset
      , payload_bytes
      , attributes
      , occurred_at
      , received_at
      , status
      , completed_at
      )
    VALUES
      ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, 'completed', $25)
    ON CONFLICT (source, dedupe_key) DO NOTHING
    RETURNING TRUE
    """
    encodedInsertEncoder
    (fmap (fromMaybe False) (D.rowMaybe (D.column (D.nonNullable D.bool))))

markCompletedStmt :: Statement (Text, Text, UTCTime) ()
markCompletedStmt =
  preparable
    """
    UPDATE keiro.keiro_inbox
    SET status = 'completed',
        completed_at = $3,
        last_error = NULL
    WHERE source = $1 AND dedupe_key = $2
    """
    ( contrazip3
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.timestamptz))
    )
    D.noResult

markFailedStmt :: Statement (Text, Text, Text, UTCTime) ()
markFailedStmt =
  preparable
    """
    UPDATE keiro.keiro_inbox
    SET status = 'failed',
        failed_at = $4,
        last_error = $3
    WHERE source = $1 AND dedupe_key = $2
    """
    ( contrazip4
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.timestamptz))
    )
    D.noResult

recordFailedAttemptStmt :: Statement EncodedFailedInsert Int
recordFailedAttemptStmt =
  preparable
    """
    INSERT INTO keiro.keiro_inbox
      ( source
      , dedupe_key
      , message_id
      , source_event_id
      , source_global_position
      , destination
      , event_type
      , schema_version
      , content_type
      , schema_registry
      , schema_subject
      , schema_version_ref
      , schema_id
      , schema_fingerprint
      , causation_id
      , correlation_id
      , traceparent
      , tracestate
      , kafka_topic
      , kafka_partition
      , kafka_offset
      , payload_bytes
      , attributes
      , occurred_at
      , received_at
      , status
      , attempt_count
      , failed_at
      , last_error
      )
    VALUES
      ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, 'failed', 1, $25, $26)
    ON CONFLICT (source, dedupe_key) DO UPDATE
    SET status = 'failed',
        attempt_count = keiro_inbox.attempt_count + 1,
        last_error = EXCLUDED.last_error,
        failed_at = EXCLUDED.failed_at
    RETURNING attempt_count
    """
    encodedFailedInsertEncoder
    (fmap fromIntegral (D.singleRow (D.column (D.nonNullable D.int8))))

selectByKeyStmt :: Statement (Text, Text) (Maybe InboxRow)
selectByKeyStmt =
  preparable
    (selectAllSql <> " WHERE source = $1 AND dedupe_key = $2")
    ( contrazip2
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.text))
    )
    (D.rowMaybe inboxRowDecoder)

listBySourceStmt :: Statement Text [InboxRow]
listBySourceStmt =
  preparable
    (selectAllSql <> " WHERE source = $1 ORDER BY received_at, dedupe_key")
    (E.param (E.nonNullable E.text))
    (D.rowList inboxRowDecoder)

countInboxBacklogStmt :: Statement () Int
countInboxBacklogStmt =
  preparable
    "SELECT COUNT(*)::bigint FROM keiro.keiro_inbox WHERE status IN ('processing', 'failed')"
    E.noParams
    (fmap fromIntegral (D.singleRow (D.column (D.nonNullable D.int8))))

gcStmt :: Statement UTCTime Int64
gcStmt =
  preparable
    """
    WITH deleted AS (
      DELETE FROM keiro.keiro_inbox
      WHERE status = 'completed' AND completed_at < $1
      RETURNING 1
    )
    SELECT COALESCE(COUNT(*), 0)::bigint FROM deleted
    """
    (E.param (E.nonNullable E.timestamptz))
    (D.singleRow (D.column (D.nonNullable D.int8)))

selectAllSql :: Text
selectAllSql =
  """
  SELECT source, dedupe_key, message_id, source_event_id, source_global_position,
         destination, event_type, schema_version, content_type, schema_registry,
         schema_subject, schema_version_ref, schema_id, schema_fingerprint,
         causation_id, correlation_id, traceparent, tracestate, kafka_topic,
         kafka_partition, kafka_offset, payload_bytes, attributes, occurred_at,
         status, attempt_count, received_at, completed_at, failed_at, last_error
  FROM keiro.keiro_inbox
  """

inboxRowDecoder :: D.Row InboxRow
inboxRowDecoder = fmap assembleInboxRow rawDecoder

data RawInbox = RawInbox
  { source :: !Text,
    dedupeKey :: !Text,
    messageId :: !(Maybe Text),
    sourceEventId :: !(Maybe EventId),
    sourceGlobalPosition :: !(Maybe GlobalPosition),
    destination :: !(Maybe Text),
    eventType :: !(Maybe Text),
    schemaVersion :: !(Maybe Int),
    contentType :: !Text,
    schemaRegistry :: !(Maybe Text),
    schemaSubject :: !(Maybe Text),
    schemaVersionRef :: !(Maybe Int),
    schemaId :: !(Maybe Int64),
    schemaFingerprint :: !(Maybe Text),
    causationId :: !(Maybe EventId),
    correlationId :: !(Maybe EventId),
    traceparent :: !(Maybe Text),
    tracestate :: !(Maybe Text),
    kafkaTopic :: !(Maybe Text),
    kafkaPartition :: !(Maybe Int64),
    kafkaOffset :: !(Maybe Int64),
    payloadBytes :: !ByteString,
    attributes :: !(Maybe Value),
    occurredAt :: !(Maybe UTCTime),
    status :: !InboxStatus,
    attemptCount :: !Int,
    receivedAt :: !UTCTime,
    completedAt :: !(Maybe UTCTime),
    failedAt :: !(Maybe UTCTime),
    lastError :: !(Maybe Text)
  }
  deriving stock (Generic)

rawDecoder :: D.Row RawInbox
rawDecoder =
  RawInbox
    <$> D.column (D.nonNullable D.text)
    <*> D.column (D.nonNullable D.text)
    <*> D.column (D.nullable D.text)
    <*> (fmap EventId <$> D.column (D.nullable D.uuid))
    <*> (fmap GlobalPosition <$> D.column (D.nullable D.int8))
    <*> D.column (D.nullable D.text)
    <*> D.column (D.nullable D.text)
    <*> (fmap fromIntegral <$> D.column (D.nullable D.int8))
    <*> D.column (D.nonNullable D.text)
    <*> D.column (D.nullable D.text)
    <*> D.column (D.nullable D.text)
    <*> (fmap fromIntegral <$> D.column (D.nullable D.int8))
    <*> D.column (D.nullable D.int8)
    <*> D.column (D.nullable D.text)
    <*> (fmap EventId <$> D.column (D.nullable D.uuid))
    <*> (fmap EventId <$> D.column (D.nullable D.uuid))
    <*> D.column (D.nullable D.text)
    <*> D.column (D.nullable D.text)
    <*> D.column (D.nullable D.text)
    <*> D.column (D.nullable D.int8)
    <*> D.column (D.nullable D.int8)
    <*> D.column (D.nonNullable D.bytea)
    <*> D.column (D.nullable D.jsonb)
    <*> D.column (D.nullable D.timestamptz)
    <*> D.column (D.nonNullable (D.refine parseInboxStatus D.text))
    <*> (fromIntegral <$> D.column (D.nonNullable D.int8))
    <*> D.column (D.nonNullable D.timestamptz)
    <*> D.column (D.nullable D.timestamptz)
    <*> D.column (D.nullable D.timestamptz)
    <*> D.column (D.nullable D.text)

assembleInboxRow :: RawInbox -> InboxRow
assembleInboxRow raw =
  let traceContext = case raw ^. #traceparent of
        Nothing -> Nothing
        Just tp -> Just (TraceContext tp (raw ^. #tracestate))
      schemaReference =
        case ( raw ^. #schemaRegistry,
               raw ^. #schemaSubject,
               raw ^. #schemaVersionRef,
               raw ^. #schemaId,
               raw ^. #schemaFingerprint
             ) of
          (Nothing, Nothing, Nothing, Nothing, Nothing) -> Nothing
          _ ->
            Just
              ( SchemaReference
                  (raw ^. #schemaRegistry)
                  (raw ^. #schemaSubject)
                  (raw ^. #schemaVersionRef)
                  (raw ^. #schemaId)
                  (raw ^. #schemaFingerprint)
              )
      kafka =
        case (raw ^. #kafkaTopic, raw ^. #kafkaPartition, raw ^. #kafkaOffset) of
          (Just t, Just p, Just o) -> Just (KafkaDeliveryRef t p o)
          _ -> Nothing
      event =
        IntegrationEvent
          { messageId = fromMaybe mempty (raw ^. #messageId),
            source = raw ^. #source,
            destination = fromMaybe mempty (raw ^. #destination),
            key = Nothing,
            -- @key@ is not part of the inbox primary key and is not
            -- carried separately on the row — the same partition info
            -- lives in @kafka@. Receivers that need it can re-derive
            -- from the payload.
            eventType = fromMaybe mempty (raw ^. #eventType),
            schemaVersion = fromMaybe 0 (raw ^. #schemaVersion),
            contentType = parseContentType (raw ^. #contentType),
            schemaReference,
            sourceEventId = raw ^. #sourceEventId,
            sourceGlobalPosition = raw ^. #sourceGlobalPosition,
            payloadBytes = raw ^. #payloadBytes,
            occurredAt = fromMaybe defaultEpoch (raw ^. #occurredAt),
            causationId = raw ^. #causationId,
            correlationId = raw ^. #correlationId,
            traceContext,
            attributes = raw ^. #attributes
          }
   in InboxRow
        { source = raw ^. #source,
          dedupeKey = raw ^. #dedupeKey,
          event,
          kafka,
          status = raw ^. #status,
          attemptCount = raw ^. #attemptCount,
          receivedAt = raw ^. #receivedAt,
          completedAt = raw ^. #completedAt,
          failedAt = raw ^. #failedAt,
          lastError = raw ^. #lastError
        }

-- | Sentinel used when an inbox row was written without an @occurred_at@.
--
-- Inbox rows are observability-shaped; consumers that care should
-- re-read the timestamp from the envelope payload directly.
defaultEpoch :: UTCTime
defaultEpoch = posixSecondsToUTCTime 0