packages feed

kiroku-store-0.1.0.0: test/Test/ConsumerGroupEffect.hs

{-# LANGUAGE NumericUnderscores #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TypeApplications #-}

{- | Consumer-group tests for the /effectful/ subscription API (ExecPlan 30 /
EP-3). EP-2's 'Test.ConsumerGroup' proves the partitioning contract through the
plain-IO 'Kiroku.Store.Subscription.subscribe'; this module proves the same
contract holds through 'Kiroku.Store.Subscription.Effect' — the higher-order
'Subscription' effect interpreted by 'runSubscription' inside an @Eff@ stack.

Two properties are proven:

  * Each member of a size-2 category group, driven through @runSubscription@ /
    @subscribe@, receives exactly its hash-assigned slice; the two slices are
    /disjoint/, their union is /complete/, and each is /per-stream-ordered/.
  * The @ConcUnlift Persistent (Limited 1)@ strategy used by 'runSubscription'
    keeps the effect environment alive across handler calls: an
    'Effectful.State.Static.Local.State' counter mutated inside the handler
    accumulates @1, 2, 3, ...@ across deliveries rather than resetting to @1@
    each call (which is what an @Ephemeral@ unlift would produce).

__Why every handler returns 'Stop' rather than relying on external cancel.__
A worker started through the effectful interpreter runs its handler via the
captured @localUnliftIO@ environment. Cancelling such a worker from outside
(e.g. through the effectful 'Kiroku.Store.Subscription.Effect.withSubscription'
bracket) while it is blocked inside that unlift does not terminate cleanly — it
hangs the worker thread. Every member here therefore stops itself once it has
seen its expected number of events, so the worker exits on its own and the
@Eff@ scope closes with no live thread (the same shape as the working effectful
test in @kiroku-store/test/Main.hs@, "catches up with an Eff-based handler via
the effectful API"). See this module's Surprises note in
@docs/plans/30-consumer-group-effect-api-and-shibuya-adapter-integration.md@.
-}
module Test.ConsumerGroupEffect (spec) where

import Control.Concurrent.STM (atomically, newTVarIO, readTVar, writeTVar)
import Control.Lens ((^.))
import Control.Monad.IO.Class (liftIO)
import Data.Aeson qualified as Aeson
import Data.Generics.Labels ()
import Data.IORef (modifyIORef', newIORef, readIORef)
import Data.Int (Int32, Int64)
import Data.List (sort)
import Data.Map.Strict qualified as Map
import Data.Text (Text)
import Data.Text qualified as T
import Data.Vector qualified as V
import Effectful (Eff, IOE, runEff, (:>))
import Effectful.State.Static.Local qualified as State
import Hasql.Pool qualified as Pool
import Hasql.Session qualified as Session
import Kiroku.Store
import Kiroku.Store.SQL qualified as SQL
import Kiroku.Store.Subscription.Effect qualified as SubEff
import Kiroku.Store.Subscription.Types (ConsumerGroup (..), SubscriptionConfigM (..))
import Test.Helpers (makeEvent, waitForPublisher, waitWithTimeout, withTestStore)
import Test.Hspec

-- | Extract @(originalStreamId, globalPosition)@ as raw 'Int64's from an event.
pairOf :: RecordedEvent -> (Int64, Int64)
pairOf e =
    ( case e ^. #originalStreamId of StreamId s -> s
    , case e ^. #globalPosition of GlobalPosition p -> p
    )

-- | Append @perStream@ events to each named stream, seeding the store.
seed :: KirokuStore -> [Text] -> Int -> IO ()
seed store names perStream =
    mapM_
        ( \sn -> do
            let evs = map (\k -> makeEvent ("E" <> T.pack (show k)) (Aeson.object [])) [1 .. perStream]
            r <- runStoreIO store $ appendToStream (StreamName sn) NoStream evs
            case r of
                Left err -> error ("seed append failed for " <> T.unpack sn <> ": " <> show err)
                Right _ -> pure ()
        )
        names

{- | Assert that within each originating stream the global positions, taken in
delivery order, are strictly ascending (i.e. equal to their sorted form).
-}
assertPerStreamAscending :: [(Int64, Int64)] -> Expectation
assertPerStreamAscending chrono =
    let byStream = Map.fromListWith (\new old -> old ++ new) [(sid, [gp]) | (sid, gp) <- chrono]
     in mapM_ (\ps -> ps `shouldBe` sort ps) (Map.elems byStream)

-- | Run a Hasql session against the store pool, failing the test on error.
runStmtP :: KirokuStore -> Session.Session a -> IO a
runStmtP store session = do
    r <- Pool.use (store ^. #pool) session
    either (error . show) pure r

{- | Drive one effectful category-group member to self-completion: it 'Stop's
after receiving exactly @k@ events, so the worker exits on its own (no external
cancel). Returns the @(streamId, globalPosition)@ pairs in delivery order. The
handler runs in @Eff es@ (needing only 'IOE'), exercising the @runSubscription@
record-update pass-through of the @consumerGroup@ field.
-}
runEffMember :: KirokuStore -> Text -> Text -> Int32 -> Int32 -> Int -> IO [(Int64, Int64)]
runEffMember store nm cat m n k = do
    ref <- newIORef []
    countVar <- newTVarIO (0 :: Int)
    let effHandler :: (IOE :> es) => RecordedEvent -> Eff es SubscriptionResult
        effHandler evt = do
            liftIO $ modifyIORef' ref (pairOf evt :)
            c <- liftIO $ atomically $ do
                c0 <- readTVar countVar
                writeTVar countVar (c0 + 1)
                pure (c0 + 1)
            pure (if c >= k then Stop else Continue)
        cfg =
            (defaultSubscriptionConfig (SubscriptionName nm) (Category (CategoryName cat)) effHandler)
                { consumerGroup = Just (ConsumerGroup{member = m, size = n})
                }
    runEff $ SubEff.runSubscription store $ do
        handle <- SubEff.subscribe cfg
        liftIO $ do
            r <- waitWithTimeout 15_000_000 handle
            case r of
                Left timeout -> expectationFailure timeout
                Right (Left err) -> expectationFailure ("effectful member failed: " <> show err)
                Right (Right ()) -> pure ()
    reverse <$> readIORef ref

spec :: Spec
spec = describe "consumer groups (effectful)" $ do
    it "routes each member through the effectful API to its own disjoint, complete, per-stream-ordered slice (size-2 category group)" $
        withTestStore $ \store -> do
            let nStreams = 40
                perStream = 3
                total = nStreams * perStream
                streams = ["effcg-" <> T.pack (show i) | i <- [1 .. nStreams]]
            seed store streams perStream
            waitForPublisher store (GlobalPosition (fromIntegral total))

            -- Deterministic ground truth: each member's slice size straight from the
            -- EP-1 partition SQL. The effectful workers must reproduce exactly these.
            let sliceSize m =
                    V.length
                        <$> runStmtP
                            store
                            ( Session.statement
                                (0 :: Int64, "effcg" :: Text, m, 2 :: Int32, 100000 :: Int32)
                                SQL.readCategoryForwardConsumerGroupStmt
                            )
            k0 <- sliceSize 0
            k1 <- sliceSize 1
            k0 `shouldSatisfy` (> 0)
            k1 `shouldSatisfy` (> 0)
            (k0 + k1) `shouldBe` total

            -- Each member is driven through runSubscription/subscribe and self-exits.
            -- Same subscription name, distinct members → independent (name, member)
            -- checkpoints, so running them in sequence is safe.
            m0 <- runEffMember store "eff-cg-cat" "effcg" 0 2 k0
            m1 <- runEffMember store "eff-cg-cat" "effcg" 1 2 k1

            -- (1) Disjoint + complete: the union of both members' delivered positions
            --     is exactly [1..total] — no duplicate and no gap.
            let allPositions = sort (map snd m0 ++ map snd m1)
            allPositions `shouldBe` [1 .. fromIntegral total]
            -- (2) Per-stream ordering preserved within each member.
            assertPerStreamAscending m0
            assertPerStreamAscending m1

    it "preserves State across handler calls within one member (Persistent unlift)" $
        withTestStore $ \store -> do
            -- Five events on one stream of category "effstate"; a size-1 member sees all.
            seed store ["effstate-1"] 5
            waitForPublisher store (GlobalPosition 5)

            -- 'seenRef' records the Effectful State value observed after each
            -- increment; 'nVar' counts deliveries and drives the Stop (so self-exit
            -- never depends on the property under test). With the Persistent unlift
            -- the cloned environment is reused across calls and the counter
            -- accumulates 1,2,3,4,5; an Ephemeral unlift would record 1 every time.
            seenRef <- newIORef ([] :: [Int])
            nVar <- newTVarIO (0 :: Int)
            runEff $
                SubEff.runSubscription store $
                    State.evalState (0 :: Int) $ do
                        let cfg =
                                ( defaultSubscriptionConfig
                                    (SubscriptionName "eff-state-sub")
                                    (Category (CategoryName "effstate"))
                                    ( \_ -> do
                                        State.modify @Int (+ 1)
                                        s <- State.get @Int
                                        liftIO (modifyIORef' seenRef (s :))
                                        c <- liftIO $ atomically $ do
                                            c0 <- readTVar nVar
                                            writeTVar nVar (c0 + 1)
                                            pure (c0 + 1)
                                        pure (if c >= 5 then Stop else Continue)
                                    )
                                )
                                    { consumerGroup = Just (ConsumerGroup{member = 0, size = 1})
                                    }
                        handle <- SubEff.subscribe cfg
                        liftIO $ do
                            r <- waitWithTimeout 10_000_000 handle
                            case r of
                                Left timeout -> expectationFailure timeout
                                Right (Left err) -> expectationFailure ("effectful state member failed: " <> show err)
                                Right (Right ()) -> pure ()

            seen <- reverse <$> readIORef seenRef
            seen `shouldBe` [1, 2, 3, 4, 5]