packages feed

keiro-pgmq-0.6.0.0: src/Keiro/PGMQ/Runtime.hs

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE TypeApplications #-}

-- | Layer 1 of @keiro-pgmq@: transport-agnostic plumbing shared by every PGMQ
-- integration (and, in the future, by case B — PGMQ as an integration-event
-- transport). This layer owns the two things every PGMQ user repeats:
--
--   1. Turning an arbitrary logical queue name into a PGMQ-legal physical name
--      plus a matching dead-letter-queue name ('QueueRef' / 'queueRef').
--   2. Running the @Pgmq : Tracing : Error PgmqRuntimeError : IOE@ effect stack
--      against a connection pool with an optional OpenTelemetry tracer
--      ('JobRuntime' / 'withJobRuntime' / 'runJobEff').
--
-- Nothing here knows about jobs, codecs, or retry policies — that is layer 2
-- ('Keiro.PGMQ.Job').
module Keiro.PGMQ.Runtime
  ( -- * Queue-name derivation
    QueueRef (..),
    queueRef,

    -- * Runtime handle and runners
    JobRuntime (..),
    withJobRuntime,
    runJobEff,

    -- * Re-exports
    PgmqRuntimeError,
  )
where

import "base" Control.Exception (bracket)
import "base" Data.Bits (xor)
import "base" Data.Char (ord)
import "base" Data.Word (Word64)
import "base" Numeric (showHex)
import "effectful-core" Effectful (Eff, IOE, runEff)
import "effectful-core" Effectful.Error.Static (Error, runErrorNoCallStack)
import "effectful-core" Effectful.Reader.Static (Reader, runReader)
import "hasql" Hasql.Connection.Settings qualified as Conn
import "hasql-pool" Hasql.Pool (Pool)
import "hasql-pool" Hasql.Pool qualified as Pool
import "hasql-pool" Hasql.Pool.Config qualified as Pool.Config
import "pgmq-core" Pgmq.Types (QueueName)
import "pgmq-core" Pgmq.Types qualified as Pgmq
import "pgmq-effectful" Pgmq.Effectful (Pgmq, PgmqRuntimeError, runPgmq, runPgmqTraced)
import "shibuya-core" Shibuya.Telemetry.Effect (Tracer, Tracing, runTracing, runTracingNoop)
import "shibuya-pgmq-adapter" Shibuya.Adapter.Pgmq (PgmqAdapterEnv, mkPgmqAdapterEnv)
import "text" Data.Text (Text)
import "text" Data.Text qualified as Text

-- | A logical queue identity plus the PGMQ-legal physical names derived from
-- it. PGMQ caps queue names at 47 characters and rejects dots and other
-- non-@[a-z0-9_]@ characters, so a caller-facing logical name (which may contain
-- dots, e.g. @"hospital_capacity.reservation_work"@) must be sanitized once,
-- centrally, into a valid physical name plus a @"_dlq"@-suffixed dead-letter name.
data QueueRef = QueueRef
  { -- | Caller-facing name; may contain dots or otherwise-illegal characters.
    logicalName :: !Text,
    -- | Derived, PGMQ-valid name used for the main queue.
    physicalName :: !QueueName,
    -- | Derived @"<physical>_dlq"@ name used for the dead-letter queue.
    dlqName :: !QueueName
  }
  deriving stock (Eq, Show)

-- | Derive a 'QueueRef' from a logical name. Total: it sanitizes rather than
-- failing. It lower-cases, replaces every character that is not @[a-z0-9_]@ with
-- @'_'@, collapses repeated underscores (also trimming leading/trailing ones),
-- and guarantees a leading letter.
--
-- Short sanitized names are used byte-for-byte unless they end in @"_dlq"@.
-- Sanitization equivalence is intentional: @"a.b"@ and @"a_b"@ name the same
-- queue, so distinct logical queues must differ after lower-casing and replacing
-- illegal characters with underscores.
--
-- When the sanitized base exceeds 43 characters, or the base ends in @"_dlq"@,
-- the physical main-queue name is @<first 26 chars>_<16 hex chars>@ where the hex
-- suffix is FNV-1a-64 over the full original logical name. This keeps the derived
-- DLQ name inside PGMQ's 47-character ceiling and establishes the invariant that
-- physical main-queue names never end in @"_dlq"@ while derived DLQ names always
-- do.
--
-- Migration note: pre-existing deployments whose sanitized logical name exceeded
-- 43 characters, or ended in @"_dlq"@, derive a different physical queue after
-- this change. Messages in the old physical queue are not lost, but new workers
-- will not read them. Drain the old queue before upgrading or temporarily run a
-- worker against the old physical name.
--
-- For example, @queueRef "hospital_capacity.reservation_work"@ yields
-- @physicalName == "hospital_capacity_reservation_work"@ and
-- @dlqName == "hospital_capacity_reservation_work_dlq"@.
queueRef :: Text -> QueueRef
queueRef logical =
  QueueRef
    { logicalName = logical,
      physicalName = forceQueueName base,
      dlqName = forceQueueName (base <> "_dlq")
    }
  where
    base = physicalBase logical

-- | Reserve 4 characters for the @"_dlq"@ suffix below the 47-char ceiling.
maxBaseLength :: Int
maxBaseLength = 43

hashedPrefixLength :: Int
hashedPrefixLength = 26

physicalBase :: Text -> Text
physicalBase logical =
  if Text.length base <= maxBaseLength && not ("_dlq" `Text.isSuffixOf` base)
    then base
    else hashedBase logical base
  where
    base = sanitize logical

sanitize :: Text -> Text
sanitize =
  ensureLeadingLetter
    . collapseUnderscores
    . Text.map toLegal
    . Text.toLower
  where
    toLegal c
      | (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' = c
      | otherwise = '_'

-- | Replace runs of underscores with a single underscore and drop leading and
-- trailing underscores.
collapseUnderscores :: Text -> Text
collapseUnderscores =
  Text.intercalate "_" . filter (not . Text.null) . Text.splitOn "_"

-- | Guarantee the name begins with an ASCII letter (PGMQ-derived table names
-- must start with a letter). Falls back to a bare @"q"@ for an empty result.
ensureLeadingLetter :: Text -> Text
ensureLeadingLetter t =
  case Text.uncons t of
    Nothing -> "q"
    Just (c, _)
      | c >= 'a' && c <= 'z' -> t
      | otherwise -> Text.cons 'q' t

hashedBase :: Text -> Text -> Text
hashedBase logical base =
  prefix <> "_" <> fnv1a64Hex logical
  where
    trimmedPrefix = Text.dropWhileEnd (== '_') (Text.take hashedPrefixLength base)
    prefix
      | Text.null trimmedPrefix = "q"
      | otherwise = trimmedPrefix

fnv1a64Hex :: Text -> Text
fnv1a64Hex logical =
  Text.pack (replicate (16 - length rendered) '0' <> rendered)
  where
    rendered = showHex (Text.foldl' step offset logical) ""
    offset :: Word64
    offset = 0xcbf29ce484222325
    prime :: Word64
    prime = 0x100000001b3
    step hash c = (hash `xor` fromIntegral (ord c)) * prime

-- | Build a 'QueueName' from an already-sanitized 'Text'. The sanitizer should
-- never produce an invalid name; if it somehow does, that is a programmer error,
-- so we fail loudly with a clear message.
forceQueueName :: Text -> QueueName
forceQueueName t =
  case Pgmq.parseQueueName t of
    Right qn -> qn
    Left err ->
      error
        ( "Keiro.PGMQ.Runtime.queueRef: derived an invalid PGMQ queue name "
            <> show t
            <> ": "
            <> show err
        )

-- | Opaque runtime: a Hasql connection pool plus an optional OpenTelemetry
-- tracer. Construct it with 'withJobRuntime' (which manages the pool lifecycle).
data JobRuntime = JobRuntime
  { runtimePool :: !Pool,
    runtimeTracer :: !(Maybe Tracer)
  }

-- | Acquire a Hasql pool from a libpq connection string, run @action@ with the
-- resulting 'JobRuntime', and release the pool afterwards (even on exception).
withJobRuntime :: Text -> Maybe Tracer -> (JobRuntime -> IO a) -> IO a
withJobRuntime connStr tracer action =
  bracket acquire Pool.release $ \pool ->
    action JobRuntime {runtimePool = pool, runtimeTracer = tracer}
  where
    acquire =
      Pool.acquire $
        Pool.Config.settings
          [ Pool.Config.staticConnectionSettings (Conn.connectionString connStr)
          ]

-- | Run a @Reader PgmqAdapterEnv : Pgmq : Tracing : Error PgmqRuntimeError : IOE@
-- effect action against the runtime, surfacing PGMQ errors as a @Left@. The
-- 'Reader' supplies the 'PgmqAdapterEnv' (built from the pool) that the
-- shibuya-pgmq adapter needs for its transactional ack/dead-letter path, so job
-- processors can construct their adapter without the pool being threaded by hand.
-- The tracer (if any) is threaded into both the shibuya 'Tracing' effect and the
-- @pgmq@ interpreter:
--
--   * @Nothing@ → 'runTracingNoop' + 'runPgmq'
--   * @Just tr@ → 'runTracing' + 'runPgmqTraced'
--
-- The interpreter order matters: @runPgmq@/@runPgmqTraced@ require
-- @Error PgmqRuntimeError :> es@ and @IOE :> es@, so we peel @Reader@ first (it is
-- pure), then @Pgmq@, then @Tracing@, then @Error@, then @IOE@.
runJobEff ::
  JobRuntime ->
  Eff '[Reader PgmqAdapterEnv, Pgmq, Tracing, Error PgmqRuntimeError, IOE] a ->
  IO (Either PgmqRuntimeError a)
runJobEff rt act =
  runEff $
    runErrorNoCallStack @PgmqRuntimeError $
      case rt.runtimeTracer of
        Nothing -> runTracingNoop (runPgmq rt.runtimePool (runReader env act))
        Just tr -> runTracing tr (runPgmqTraced rt.runtimePool tr (runReader env act))
  where
    env = mkPgmqAdapterEnv rt.runtimePool