packages feed

kiroku-store-0.10.0.0: test/Test/ConsumerGroupResize.hs

{-# LANGUAGE MultilineStrings #-}
{-# LANGUAGE NumericUnderscores #-}

module Test.ConsumerGroupResize (spec) where

import Control.Concurrent (threadDelay)
import Control.Concurrent.Async qualified as Async
import Control.Concurrent.STM
import Control.Exception (SomeException, fromException)
import Control.Lens ((^.))
import Control.Monad (forM_, void)
import Data.Aeson qualified as Aeson
import Data.Generics.Labels ()
import Data.IORef
import Data.Int (Int32, Int64)
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Vector qualified as Vector
import Hasql.Decoders qualified as D
import Hasql.Encoders qualified as E
import Hasql.Pool qualified as Pool
import Hasql.Session qualified as Session
import Hasql.Statement (preparable)
import Hasql.Transaction qualified as Tx
import Hasql.Transaction.Sessions qualified as TxSessions
import Kiroku.Store
import Kiroku.Store.SQL qualified as SQL
import System.Timeout (timeout)
import Test.Helpers (makeEvent, validConsumerGroup, waitWithTimeout, withTestStore, withTestStoreSettings)
import Test.Hspec

spec :: Spec
spec = describe "consumer-group resize" $ do
    it "rejects invalid sizes and member indices at construction" $ do
        mkConsumerGroupSize 0 `shouldBe` Left (InvalidConsumerGroup 0 0)
        mkConsumerGroupSize (-1) `shouldBe` Left (InvalidConsumerGroup 0 (-1))
        let size2 = groupSize 2
        mkConsumerGroup (-1) size2 `shouldBe` Left (InvalidConsumerGroup (-1) 2)
        mkConsumerGroup 2 size2 `shouldBe` Left (InvalidConsumerGroup 2 2)
        mkConsumerGroup 1 size2 `shouldBe` Right (validConsumerGroup 1 2)

    it "refuses a configured size that disagrees with stored topology before creating a member" $ do
        refusals <- newIORef []
        let observe = \case
                KirokuEventSubscriptionGroupSizeMismatch refusal group -> modifyIORef' refusals ((refusal, group) :)
                _ -> pure ()
        withTestStoreSettings (\settings -> settings{eventHandler = Just observe}) $ \store -> do
            seedCheckpoint store "mismatch" 0 2 5
            called <- newIORef False
            handle <- subscribe store $ config "mismatch" 2 3 $ \_ -> writeIORef called True >> pure Continue
            expectMismatch handle "mismatch" 3 [2]
            readIORef called `shouldReturn` False
            rows store "mismatch" `shouldReturn` [(0, 2, 5)]
            readIORef refusals `shouldReturn` [(ConsumerGroupSizeMismatch (SubscriptionName "mismatch") 3 (Vector.singleton 2), GroupMember 2 3)]

    it "also refuses an ordinary subscription over an existing larger group" $
        withTestStore $ \store -> do
            seedCheckpoint store "ordinary-mismatch" 0 2 0
            handle <- subscribe store (defaultSubscriptionConfig (SubscriptionName "ordinary-mismatch") AllStreams (\_ -> pure Continue))
            expectMismatch handle "ordinary-mismatch" 1 [2]

    it "refuses an underestimated derived topology until resized" $
        withTestStore $ \store -> do
            -- The upgrade test proves a lone legacy member derives size 1.
            seedCheckpoint store "underestimated" 0 1 8
            refused <- subscribe store (config "underestimated" 0 2 (\_ -> pure Continue))
            expectMismatch refused "underestimated" 2 [1]
            void $ resize store "underestimated" 2
            accepted <- subscribe store (config "underestimated" 0 2 (\_ -> pure Continue))
            awaitLive accepted
            cancel accepted
            rows store "underestimated" `shouldReturn` [(0, 2, 8), (1, 2, 8)]

    it "refuses mixed stored sizes even when its own row agrees" $
        withTestStore $ \store -> do
            seedCheckpoint store "mixed" 0 2 0
            seedCheckpoint store "mixed" 1 3 0
            handle <- subscribe store (config "mixed" 0 2 (\_ -> pure Continue))
            expectMismatch handle "mixed" 2 [2, 3]

    it "serializes concurrent initializers with competing group sizes" $
        withTestStore $ \store -> do
            [firstHandle, secondHandle] <- Async.mapConcurrently (\n -> subscribe store (config "concurrent" 0 n (\_ -> pure Continue))) [2, 3]
            -- The winner reaches Live; the loser exits with a typed refusal.
            result <- timeout 5_000_000 $ Async.race (wait firstHandle) (wait secondHandle)
            case result of
                Just (Left outcome) -> assertMismatch outcome
                Just (Right outcome) -> assertMismatch outcome
                Nothing -> expectationFailure "competing topology startup timed out"
            mapM_ cancel [firstHandle, secondHandle]
            stored <- rows store "concurrent"
            length stored `shouldBe` 1
            stored `shouldSatisfy` \case [(0, n, 0)] -> n == 2 || n == 3; _ -> False

    it "persists configured topology on initialization, ordinary saves and dead-letter saves" $
        withTestStore $ \store -> do
            void $ runStoreIO store $ appendToStream (StreamName "persist-1") NoStream [makeEvent "E" (Aeson.object [])]
            -- A size-1 group exercises all rows deterministically. Size >1 is
            -- checked below via explicit ordinary and dead-letter SQL paths.
            handle <- subscribe store (config "saved" 0 1 (\_ -> pure Stop))
            expectClean handle
            rows store "saved" `shouldReturn` [(0, 1, 1)]
            live <- subscribe store ((config "initialized" 2 3 (\_ -> pure Continue)){missingCheckpointPolicy = FromCurrentHead})
            awaitLive live
            cancel live
            rows store "initialized" `shouldReturn` [(2, 3, 1)]
            seedCheckpoint store "saved-size" 1 4 20
            seedCheckpoint store "saved-size" 1 4 10
            rows store "saved-size" `shouldReturn` [(1, 4, 20)]
            Right events <- runStoreIO store (readAllForward (GlobalPosition 0) 10)
            let event = Vector.head events
                EventId eid = event ^. #eventId
                params = SQL.DeadLetterParams "dead-letter-size" 1 "unbound" Nothing 4 1 eid (Aeson.object []) "test" 1
            runSession store (Session.statement params SQL.insertDeadLetterAndCheckpointStmt)
            rows store "dead-letter-size" `shouldReturn` [(1, 4, 1)]
            let StreamId sid = event ^. #originalStreamId
                ownerStmt =
                    preparable
                        "SELECT (((hashtextextended($1::bigint::text, 0) % 4) + 4) % 4)::int4"
                        (E.param (E.nonNullable E.int8))
                        (D.singleRow (D.column (D.nonNullable D.int4)))
            owner <- runSession store (Session.statement sid ownerStmt)
            saved <- subscribe store (config "worker-save-size" owner 4 (\_ -> pure Stop))
            expectClean saved
            rows store "worker-save-size" `shouldReturn` [(owner, 4, 1)]
            deadLettered <- subscribe store (config "worker-dead-letter-size" owner 4 (\_ -> pure (DeadLetter (DeadLetterPoison "explicit consumer decision"))))
            awaitLive deadLettered
            cancel deadLettered
            rows store "worker-dead-letter-size" `shouldReturn` [(owner, 4, 1)]

    it "delivers every seeded event after equalizing size 2 to size 3" $
        withTestStore $ \store -> do
            forM_ [1 .. 40 :: Int] $ \i -> do
                result <- runStoreIO store $ appendToStream (StreamName ("resize-" <> Text.pack (show i))) NoStream [makeEvent "E" (Aeson.object [])]
                result `shouldSatisfy` either (const False) (const True)
            Right events <- runStoreIO store (readAllForward (GlobalPosition 0) 100)
            let expected = Set.fromList [event ^. #eventId | event <- Vector.toList events]
            -- Member 0 has seen nothing, member 1 has crossed the full log.
            -- Remember its actual delivered set; a naive size change loses
            -- streams that move from old member 0 to new member 1.
            oldFast <- runSession store (Session.statement (0, 1, 2, 100) SQL.readAllForwardConsumerGroupStmt)
            naive <- mapM (\(m, p) -> runSession store (Session.statement (p, m, 3, 100) SQL.readAllForwardConsumerGroupStmt)) [(0, 0), (1, 40), (2, 0)]
            let alreadySeen = Set.fromList [e ^. #eventId | e <- Vector.toList oldFast]
                naiveSeen = Set.fromList [e ^. #eventId | batch <- naive, e <- Vector.toList batch]
            Set.union alreadySeen naiveSeen `shouldNotBe` expected
            seedCheckpoint store "skewed" 0 2 0
            seedCheckpoint store "skewed" 1 2 40
            refused <- subscribe store (config "skewed" 1 3 (\_ -> pure Continue))
            expectMismatch refused "skewed" 3 [2]
            report <- resize store "skewed" 3
            report `shouldBe` ConsumerGroupResizeReport (Vector.singleton 2) 2 (groupSize 3) (GlobalPosition 0)
            delivered <- newTVarIO alreadySeen
            handles <- mapM (\m -> subscribe store (config "skewed" m 3 (\e -> atomically (modifyTVar' delivered (Set.insert (e ^. #eventId))) >> pure Continue))) [0, 1, 2]
            complete <- timeout 15_000_000 (atomically (readTVar delivered >>= check . (== expected)))
            mapM_ cancel handles
            complete `shouldBe` Just ()
            readTVarIO delivered `shouldReturn` expected

    it "is idempotent when repeated at the same size and retains surviving member identities" $
        withTestStore $ \store -> do
            seedCheckpoint store "repeat" 0 2 5
            seedCheckpoint store "repeat" 1 2 20
            oldIds <- memberIds store "repeat"
            void $ resize store "repeat" 3
            originalRows <- rows store "repeat"
            resizedIds <- memberIds store "repeat"
            take 2 resizedIds `shouldBe` oldIds
            second <- resize store "repeat" 3
            rows store "repeat" `shouldReturn` originalRows
            memberIds store "repeat" `shouldReturn` resizedIds
            originalRows `shouldBe` [(0, 3, 5), (1, 3, 5), (2, 3, 5)]
            resumePosition second `shouldBe` GlobalPosition 5
            previousMemberCount second `shouldBe` 3

    it "starts a missing group at zero and removes obsolete members on shrink" $
        withTestStore $ \store -> do
            report <- resize store "missing" 3
            previousMemberCount report `shouldBe` 0
            resumePosition report `shouldBe` GlobalPosition 0
            rows store "missing" `shouldReturn` [(0, 3, 0), (1, 3, 0), (2, 3, 0)]
            void $ resize store "missing" 1
            rows store "missing" `shouldReturn` [(0, 1, 0)]

    it "rolls back the complete resize with the caller transaction" $
        withTestStore $ \store -> do
            seedCheckpoint store "rollback" 0 2 5
            seedCheckpoint store "rollback" 1 2 20
            void $ runSession store $ TxSessions.transaction TxSessions.ReadCommitted TxSessions.Write $ do
                report <- resizeConsumerGroupTx (SubscriptionName "rollback") (groupSize 3)
                Tx.condemn
                pure report
            rows store "rollback" `shouldReturn` [(0, 2, 5), (1, 2, 20)]

groupSize :: Int32 -> ConsumerGroupSize
groupSize = either (error . show) Prelude.id . mkConsumerGroupSize

config :: Text -> Int32 -> Int32 -> EventHandler -> SubscriptionConfig
config name m n handler =
    (defaultSubscriptionConfig (SubscriptionName name) AllStreams handler)
        { consumerGroup = Just (validConsumerGroup m n)
        }

seedCheckpoint :: KirokuStore -> Text -> Int32 -> Int32 -> Int64 -> IO ()
seedCheckpoint store name m n p = runSession store (Session.statement (name, m, p, n, "unbound", Nothing) SQL.saveCheckpointMemberStmt)

resize :: KirokuStore -> Text -> Int32 -> IO ConsumerGroupResizeReport
resize store name n = runSession store $ TxSessions.transaction TxSessions.ReadCommitted TxSessions.Write (resizeConsumerGroupTx (SubscriptionName name) (groupSize n))

rows :: KirokuStore -> Text -> IO [(Int32, Int32, Int64)]
rows store name = Vector.toList <$> runSession store (Session.statement name stmt)
  where
    stmt =
        preparable
            "SELECT consumer_group_member, consumer_group_size, last_seen FROM subscriptions WHERE subscription_name = $1 ORDER BY consumer_group_member"
            (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)))

runSession :: KirokuStore -> Session.Session a -> IO a
runSession store session = Pool.use (store ^. #pool) session >>= either (error . show) pure

expectMismatch :: SubscriptionHandle -> Text -> Int32 -> [Int32] -> IO ()
expectMismatch handle name n observed = do
    result <- waitWithTimeout 5_000_000 handle
    case result of
        Right (Left exception) -> fromException exception `shouldBe` Just (ConsumerGroupSizeMismatch (SubscriptionName name) n (Vector.fromList observed))
        other -> expectationFailure ("expected topology mismatch, got " <> show other)

assertMismatch :: Either SomeException () -> IO ()
assertMismatch = \case
    Left exception -> (fromException exception :: Maybe ConsumerGroupSizeMismatch) `shouldSatisfy` maybe False (const True)
    Right () -> expectationFailure "expected competing-size startup refusal"

expectClean :: SubscriptionHandle -> IO ()
expectClean handle =
    waitWithTimeout 5_000_000 handle >>= \case
        Right (Right ()) -> pure ()
        other -> expectationFailure (show other)

awaitLive :: SubscriptionHandle -> IO ()
awaitLive handle = do
    result <- timeout 5_000_000 loop
    result `shouldBe` Just ()
  where
    loop =
        currentState handle >>= \case
            Just state | stateName state == "live" -> pure ()
            _ -> threadDelay 1_000 >> loop

memberIds :: KirokuStore -> Text -> IO [Int64]
memberIds store name =
    runSession store $
        Session.statement name $
            preparable
                "SELECT subscription_id FROM subscriptions WHERE subscription_name = $1 ORDER BY consumer_group_member"
                (E.param (E.nonNullable E.text))
                (D.rowList (D.column (D.nonNullable D.int8)))