packages feed

kiroku-store-0.12.0.0: src/Kiroku/Store/Subscription/Checkpoint/SQL.hs

{-# LANGUAGE MultilineStrings #-}
{-# LANGUAGE RankNTypes #-}

-- | Package-internal SQL for subscription checkpoint lifecycle operations.
module Kiroku.Store.Subscription.Checkpoint.SQL
  ( initializeSubscriptionCheckpointSession,
    initializeWorkerCheckpointSession,
    lockCheckpointNameStmt,
    lockCheckpointRowsStmt,
    resizeCheckpointMembersStmt,
    resetSubscriptionCheckpointsStmt,
    targetColumns,
    saveBoundCheckpointSession,
    rebindCheckpointTargetStmt,
  )
where

import Contravariant.Extras (contrazip2, contrazip4, contrazip5, contrazip6)
import Data.Int (Int32, Int64)
import Data.List (nub, sort)
import Data.Text (Text)
import Data.Vector (Vector)
import Data.Vector qualified as Vector
import Hasql.Decoders qualified as D
import Hasql.Encoders qualified as E
import Hasql.Session qualified as Session
import Hasql.Statement (Statement, preparable)
import Hasql.Transaction qualified as Tx
import Hasql.Transaction.Sessions qualified as TxSessions
import Kiroku.Store.SQL qualified as SQL
import Kiroku.Store.Subscription.Types
  ( CheckpointInitialization (..),
    ConsumerGroupSizeMismatch (..),
    MissingCheckpointPolicy (..),
    SomeSubscriptionStartupFailure (..),
    SubscriptionCheckpointKey (..),
    SubscriptionCheckpointMissing (..),
    SubscriptionName (..),
    SubscriptionTarget (..),
    SubscriptionTargetMismatch (..),
    TargetBindingPolicy (..),
  )
import Kiroku.Store.Types (CategoryName (..), GlobalPosition (..))

-- | Resolve one checkpoint key in a Hasql session.
--
-- The name lock serializes topology adoption with worker startup, resize and
-- rebind. A newly inserted member inherits uniform sibling size and binding;
-- empty or inconsistent sets retain the size-1/unbound defaults. Existing rows
-- always win and sibling rows are never repaired here.
--
-- The insert statement inserts the policy-selected position with @ON CONFLICT DO
-- NOTHING@ and then reads the winning row. PostgreSQL can report no row to that
-- final read when another transaction committed the conflicting insert after the
-- statement snapshot was taken. For an initializing policy, a second statement
-- therefore reads the now-committed winner on a fresh snapshot. 'FailIfMissing'
-- does not retry because it never attempts an insert.
initializeSubscriptionCheckpointSession ::
  SubscriptionName ->
  Int32 ->
  MissingCheckpointPolicy ->
  Session.Session (Either SubscriptionCheckpointMissing CheckpointInitialization)
initializeSubscriptionCheckpointSession subscriptionName@(SubscriptionName name) member policy =
  TxSessions.transaction TxSessions.ReadCommitted TxSessions.Write $ do
    Tx.statement name lockCheckpointNameStmt
    rows <- Tx.statement name readCheckpointRowsStmt
    let topologies = nub [(size, siblingBinding) | (_, size, _, siblingBinding) <- Vector.toList rows]
        (groupSize, binding) = case topologies of
          [uniform] -> uniform
          _ -> (1, Nothing)
    initializeCheckpointWith Tx.statement subscriptionName member groupSize binding policy

-- | Startup-only validation and insertion share one checkout and one transaction.
-- The name lock serializes competing topologies, including an initially absent
-- row set. It is never acquired by ordinary checkpoint saves or event appends.
initializeWorkerCheckpointSession ::
  SubscriptionName ->
  Int32 ->
  Int32 ->
  SubscriptionTarget ->
  TargetBindingPolicy ->
  MissingCheckpointPolicy ->
  Session.Session (Either SomeSubscriptionStartupFailure (CheckpointInitialization, Bool))
initializeWorkerCheckpointSession subscriptionName@(SubscriptionName name) member configured target bindingPolicy policy =
  TxSessions.transaction TxSessions.ReadCommitted TxSessions.Write $ do
    Tx.statement name lockCheckpointNameStmt
    rows <- Tx.statement name readCheckpointRowsStmt
    let sizes = Vector.fromList . sort . nub . fmap (\(_, n, _, _) -> n) $ Vector.toList rows
        bindings = Vector.fromList . nub $ [binding | (_, _, _, binding) <- Vector.toList rows]
        unbound = not (Vector.null rows) && bindings == Vector.singleton Nothing
        matches = Vector.all (== Just target) bindings
    if Vector.any (/= configured) sizes
      then pure (Left (SomeSubscriptionStartupFailure (ConsumerGroupSizeMismatch subscriptionName configured sizes)))
      else
        if not matches && not (unbound && bindingPolicy == AdoptUnbound)
          then pure (Left (SomeSubscriptionStartupFailure (SubscriptionTargetMismatch subscriptionName target bindings)))
          else do
            -- Check exact-key absence before adoption so a refused startup never
            -- mutates sibling bindings. Both operations share the name lock.
            resolution <- initializeCheckpointWith Tx.statement subscriptionName member configured (Just target) policy
            case resolution of
              Left missing -> pure (Left (SomeSubscriptionStartupFailure missing))
              Right initialized -> do
                if unbound then Tx.statement (name, targetColumns (Just target)) adoptCheckpointTargetStmt else pure ()
                pure (Right (initialized, unbound))

initializeCheckpointWith ::
  (Monad m) =>
  (forall a b. a -> Statement a b -> m b) ->
  SubscriptionName ->
  Int32 ->
  Int32 ->
  Maybe SubscriptionTarget ->
  MissingCheckpointPolicy ->
  m (Either SubscriptionCheckpointMissing CheckpointInitialization)
initializeCheckpointWith statement subscriptionName@(SubscriptionName name) member groupSize binding policy = do
  first <- statement (name, member, policyCode policy, groupSize, fst (targetColumns binding), snd (targetColumns binding)) initializeSubscriptionCheckpointStmt
  case first of
    Just result -> pure (Right (decodeResult result))
    Nothing -> case policy of
      FailIfMissing -> pure (Left missing)
      _ -> do
        -- The insert lost a concurrent unique-key race after this
        -- statement's snapshot. A fresh statement snapshot observes
        -- the committed winner; singleRow turns a violated invariant
        -- into a structured Hasql session error.
        position <- statement (name, member) readInitializedCheckpointStmt
        pure (Right (ExistingCheckpoint key (GlobalPosition position)))
  where
    key = SubscriptionCheckpointKey subscriptionName member
    missing = SubscriptionCheckpointMissing key
    decodeResult (position, inserted)
      | inserted = InitializedCheckpoint policy key (GlobalPosition position)
      | otherwise = ExistingCheckpoint key (GlobalPosition position)

policyCode :: MissingCheckpointPolicy -> Text
policyCode = \case
  FromBeginning -> "from_beginning"
  FromCurrentHead -> "from_current_head"
  FailIfMissing -> "fail_if_missing"

initializeSubscriptionCheckpointStmt :: Statement (Text, Int32, Text, Int32, Text, Maybe Text) (Maybe (Int64, Bool))
initializeSubscriptionCheckpointStmt =
  preparable
    """
    WITH desired AS (
      SELECT CASE $3
               WHEN 'from_beginning' THEN 0::bigint
               WHEN 'from_current_head' THEN (
                 SELECT stream_version FROM streams WHERE stream_id = 0
               )
               ELSE NULL::bigint
             END AS last_seen
    ),
    inserted AS (
      INSERT INTO subscriptions
        (subscription_name, consumer_group_member, last_seen, updated_at, consumer_group_size, target_kind, target_category)
      SELECT $1, $2, desired.last_seen, now(), $4, $5, $6
      FROM desired
      WHERE desired.last_seen IS NOT NULL
      ON CONFLICT (subscription_name, consumer_group_member) DO NOTHING
      RETURNING last_seen
    )
    SELECT inserted.last_seen, TRUE AS initialized
    FROM inserted
    UNION ALL
    SELECT subscriptions.last_seen, FALSE AS initialized
    FROM subscriptions
    WHERE subscription_name = $1
      AND consumer_group_member = $2
    LIMIT 1
    """
    ( contrazip6
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.int4))
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.int4))
        (E.param (E.nonNullable E.text))
        (E.param (E.nullable E.text))
    )
    ( D.rowMaybe $
        (,)
          <$> D.column (D.nonNullable D.int8)
          <*> D.column (D.nonNullable D.bool)
    )

readInitializedCheckpointStmt :: Statement (Text, Int32) Int64
readInitializedCheckpointStmt =
  preparable
    """
    SELECT last_seen
    FROM subscriptions
    WHERE subscription_name = $1
      AND consumer_group_member = $2
    """
    ( contrazip2
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.int4))
    )
    (D.singleRow (D.column (D.nonNullable D.int8)))

-- | Reset every persisted member belonging to the requested subscription
-- names. The input is treated as a set by PostgreSQL. Each returned row contains
-- either one reset member or a requested name with no persisted rows, and the
-- result is deterministically ordered by name and member.
--
-- This statement deliberately assigns @last_seen@ directly. Ordinary worker
-- saves retain their separate @GREATEST(...)@ monotonicity contract.
resetSubscriptionCheckpointsStmt ::
  Statement (Vector Text, Int64) (Vector (Text, Maybe Int32))
resetSubscriptionCheckpointsStmt =
  preparable
    """
    WITH requested AS (
      SELECT DISTINCT requested_name AS subscription_name
      FROM unnest($1::text[]) AS requested_name
    ),
    updated AS (
      UPDATE subscriptions AS checkpoint
      SET last_seen = $2, updated_at = now()
      FROM requested
      WHERE checkpoint.subscription_name = requested.subscription_name
      RETURNING checkpoint.subscription_name, checkpoint.consumer_group_member
    )
    SELECT requested.subscription_name, updated.consumer_group_member
    FROM requested
    LEFT JOIN updated USING (subscription_name)
    ORDER BY requested.subscription_name, updated.consumer_group_member
    """
    ( contrazip2
        (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.text))))
        (E.param (E.nonNullable E.int8))
    )
    ( D.rowVector $
        (,)
          <$> D.column (D.nonNullable D.text)
          <*> D.column (D.nullable D.int4)
    )

-- | A separate key domain from the optional member guard.
lockCheckpointNameStmt :: Statement Text ()
lockCheckpointNameStmt =
  preparable
    "SELECT pg_advisory_xact_lock(hashtextextended('kiroku:checkpoint-topology:' || $1, 0))"
    (E.param (E.nonNullable E.text))
    D.noResult

readCheckpointRowsStmt :: Statement Text (Vector (Int32, Int32, Int64, Maybe SubscriptionTarget))
readCheckpointRowsStmt = checkpointRowsStmt ""

lockCheckpointRowsStmt :: Statement Text (Vector (Int32, Int32, Int64, Maybe SubscriptionTarget))
lockCheckpointRowsStmt = checkpointRowsStmt " FOR UPDATE"

checkpointRowsStmt :: Text -> Statement Text (Vector (Int32, Int32, Int64, Maybe SubscriptionTarget))
checkpointRowsStmt suffix =
  preparable
    ("SELECT consumer_group_member, consumer_group_size, last_seen, target_kind, target_category FROM subscriptions WHERE subscription_name = $1 ORDER BY consumer_group_member" <> suffix)
    (E.param (E.nonNullable E.text))
    (D.rowVector ((,,,) <$> D.column (D.nonNullable D.int4) <*> D.column (D.nonNullable D.int4) <*> D.column (D.nonNullable D.int8) <*> targetBindingRow))

-- | Keep existing row identities, equalize every new member, remove obsolete
-- members. Caller already holds the name and row locks; workers must be stopped.
resizeCheckpointMembersStmt :: Statement (Text, Int32, Int64, Text, Maybe Text) ()
resizeCheckpointMembersStmt =
  preparable
    """
    WITH removed AS (
      DELETE FROM subscriptions
      WHERE subscription_name = $1 AND consumer_group_member >= $2
    )
    INSERT INTO subscriptions
      (subscription_name, consumer_group_member, consumer_group_size, last_seen, updated_at, target_kind, target_category)
    SELECT $1, member, $2, $3, now(), $4, $5 FROM generate_series(0, $2 - 1) AS member
    ON CONFLICT (subscription_name, consumer_group_member) DO UPDATE
    SET consumer_group_size = EXCLUDED.consumer_group_size,
        last_seen = EXCLUDED.last_seen, updated_at = now()
    """
    ( contrazip5
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.int4))
        (E.param (E.nonNullable E.int8))
        (E.param (E.nonNullable E.text))
        (E.param (E.nullable E.text))
    )
    D.noResult

-- | Select a fixed-kind statement once for the configured target. Both forms
-- write the full binding and retain the same unconditional monotonic upsert.
saveBoundCheckpointSession :: SubscriptionTarget -> Text -> Int32 -> Int64 -> Int32 -> Session.Session ()
saveBoundCheckpointSession AllStreams name member position groupSize =
  Session.statement (name, member, position, groupSize) SQL.saveAllCheckpointMemberStmt
saveBoundCheckpointSession (Category (CategoryName category)) name member position groupSize =
  Session.statement (name, member, position, groupSize, category) SQL.saveCategoryCheckpointMemberStmt
saveBoundCheckpointSession (CategoryNamespace namespace) name member position groupSize =
  Session.statement (name, member, position, groupSize, namespace) SQL.saveNamespaceCheckpointMemberStmt

-- | One encoder for every checkpoint target write, including legacy provisioning.
targetColumns :: Maybe SubscriptionTarget -> (Text, Maybe Text)
targetColumns Nothing = ("unbound", Nothing)
targetColumns (Just AllStreams) = ("all", Nothing)
targetColumns (Just (Category (CategoryName category))) = ("category", Just category)
targetColumns (Just (CategoryNamespace namespace)) = ("category_namespace", Just namespace)

-- The CHECK constraints make this decoder total over valid database rows.
targetBindingRow :: D.Row (Maybe SubscriptionTarget)
targetBindingRow = decode <$> D.column (D.nonNullable D.text) <*> D.column (D.nullable D.text)
  where
    decode "all" Nothing = Just AllStreams
    decode "category" (Just category) = Just (Category (CategoryName category))
    decode "category_namespace" (Just namespace) = Just (CategoryNamespace namespace)
    decode _ _ = Nothing

adoptCheckpointTargetStmt :: Statement (Text, (Text, Maybe Text)) ()
adoptCheckpointTargetStmt =
  preparable
    "UPDATE subscriptions SET target_kind = $2, target_category = $3 WHERE subscription_name = $1"
    (contrazip2 (E.param (E.nonNullable E.text)) (contrazip2 (E.param (E.nonNullable E.text)) (E.param (E.nullable E.text))))
    D.noResult

-- A singleRow decoder refuses an absent name and aborts the surrounding transaction.
rebindCheckpointTargetStmt :: Statement (Text, Text, Maybe Text, Int64) Int64
rebindCheckpointTargetStmt =
  preparable
    """
    WITH rebound AS (
      UPDATE subscriptions
      SET target_kind = $2, target_category = $3, last_seen = $4, updated_at = now()
      WHERE subscription_name = $1
      RETURNING subscription_id
    )
    SELECT count(*) FROM rebound HAVING count(*) > 0
    """
    (contrazip4 (E.param (E.nonNullable E.text)) (E.param (E.nonNullable E.text)) (E.param (E.nullable E.text)) (E.param (E.nonNullable E.int8)))
    (D.singleRow (D.column (D.nonNullable D.int8)))