kiroku-store-0.1.0.0: test/Test/ConsumerGroup.hs
{-# LANGUAGE NumericUnderscores #-}
{-# LANGUAGE OverloadedStrings #-}
{- | Runtime tests for consumer groups (ExecPlan 29 / EP-2). Each member runs as
one in-process subscription worker over a fresh ephemeral PostgreSQL. The
properties proven here are the user-visible contract: a group's members deliver a
/disjoint/, /complete/, /per-stream-ordered/ partition of the source; a size-1
group equals a plain subscription; and each member resumes from its /own/
checkpoint.
-}
module Test.ConsumerGroup (spec) where
import Control.Concurrent (threadDelay)
import Control.Concurrent.STM (atomically, newTVarIO, readTVar, writeTVar)
import Control.Exception (fromException)
import Control.Lens ((&), (.~), (^.))
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 EphemeralPg qualified as Pg
import Hasql.Connection qualified as Connection
import Hasql.Connection.Settings qualified as Conn
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 (Statement, preparable)
import Kiroku.Store
import Kiroku.Store.SQL qualified as SQL
import Kiroku.Store.Subscription.Stream (subscriptionStream)
import Kiroku.Store.Subscription.Types (ConsumerGroup (..), SubscriptionConfigM (..))
import Streamly.Data.Stream qualified as Stream
import Test.Helpers (makeEvent, waitForPublisher, waitWithTimeout, withTestStore, withTestStoreSettings)
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
-- | Poll an @IO Bool@ predicate until it holds or the microsecond budget runs out.
waitUntil :: Int -> IO Bool -> IO ()
waitUntil budget act
| budget <= 0 = pure ()
| otherwise = do
ok <- act
if ok
then pure ()
else do
threadDelay 20_000
waitUntil (budget - 20_000) act
{- | 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)
-- | A size-@n@ category-group config for member @m@ with the given handler.
memberConfig ::
Text -> Text -> Int32 -> Int32 -> EventHandler -> SubscriptionConfig
memberConfig nm cat m n h =
(defaultSubscriptionConfig (SubscriptionName nm) (Category (CategoryName cat)) h)
{ consumerGroup = Just (ConsumerGroup{member = m, size = n})
}
-- | A size-@n@ @$all@-group config for member @m@ with the given handler.
memberConfigAll ::
Text -> Int32 -> Int32 -> EventHandler -> SubscriptionConfig
memberConfigAll nm m n h =
(defaultSubscriptionConfig (SubscriptionName nm) AllStreams h)
{ consumerGroup = Just (ConsumerGroup{member = m, size = n})
}
{- | Run a subscription built from the given config-completer, collecting the
global positions it delivers and stopping the handler after @k@ events. Returns
the @k@ positions in delivery order.
-}
collectStopAfter :: KirokuStore -> (EventHandler -> SubscriptionConfig) -> Int -> IO [Int64]
collectStopAfter store mkCfg k = do
ref <- newIORef []
countVar <- newTVarIO (0 :: Int)
let h evt = do
modifyIORef' ref (snd (pairOf evt) :)
c <- atomically $ do
c0 <- readTVar countVar
let c1 = c0 + 1
writeTVar countVar c1
pure c1
pure (if c >= k then Stop else Continue)
handle <- subscribe store (mkCfg h)
_ <- waitWithTimeout 15_000_000 handle
reverse <$> readIORef ref
-- | Run a Hasql session against the store pool, failing the test on a usage error.
runStmtP :: KirokuStore -> Session.Session a -> IO a
runStmtP store session = do
r <- Pool.use (store ^. #pool) session
either (error . show) pure r
spec :: Spec
spec = describe "consumer groups" $ do
it "delivers a disjoint, complete, per-stream-ordered partition (size-4 category group)" $
withTestStore $ \store -> do
let nStreams = 40
perStream = 3
total = nStreams * perStream
streams = ["acct-" <> T.pack (show i) | i <- [1 .. nStreams]]
seed store streams perStream
waitForPublisher store (GlobalPosition (fromIntegral total))
let n = 4 :: Int32
refs <- mapM (const (newIORef [])) [0 .. n - 1]
handles <-
mapM
( \m -> do
let ref = refs !! fromIntegral m
h evt = do
modifyIORef' ref (pairOf evt :)
pure Continue
subscribe store (memberConfig "cg-cat" "acct" m n h)
)
[0 .. n - 1]
let collectedCount = sum <$> mapM (fmap length . readIORef) refs
waitUntil 15_000_000 (fmap (>= total) collectedCount)
mapM_ cancel handles
collected <- mapM readIORef refs
-- (1) Disjoint + complete: the multiset of all delivered positions is
-- exactly [1..total] — no duplicate (would lengthen it) and no gap.
let allPositions = sort (concatMap (map snd) collected)
allPositions `shouldBe` [1 .. fromIntegral total]
-- (2) Per-stream ordering within each member (collector prepended, so
-- reverse to recover delivery order).
mapM_ (assertPerStreamAscending . reverse) collected
it "size-1 group delivers the same set as a plain subscription" $
withTestStore $ \store -> do
let nStreams = 4
perStream = 3
total = nStreams * perStream
streams = ["uno-" <> T.pack (show i) | i <- [1 .. nStreams]]
seed store streams perStream
waitForPublisher store (GlobalPosition (fromIntegral total))
let runCollector cfgFor nm = do
ref <- newIORef []
countVar <- newTVarIO (0 :: Int)
let h evt = do
modifyIORef' ref (snd (pairOf evt) :)
c <- atomically $ do
c0 <- readTVar countVar
let c1 = c0 + 1
writeTVar countVar c1
pure c1
pure (if c >= total then Stop else Continue)
handle <- subscribe store (cfgFor nm h)
_ <- waitWithTimeout 15_000_000 handle
sort <$> readIORef ref
plain <-
runCollector
(\nm h -> defaultSubscriptionConfig (SubscriptionName nm) (Category (CategoryName "uno")) h)
"uno-plain"
grouped <-
runCollector
(\nm h -> memberConfig nm "uno" 0 1 h)
"uno-group"
plain `shouldBe` [1 .. fromIntegral total]
grouped `shouldBe` plain
it "$all group partitions the whole store across members" $
withTestStore $ \store -> do
let cats = ["acct", "user", "order"]
perCat = 10
perStream = 2
streams = [c <> "-" <> T.pack (show i) | c <- cats, i <- [1 .. perCat]]
total = length streams * perStream
seed store streams perStream
waitForPublisher store (GlobalPosition (fromIntegral total))
let n = 4 :: Int32
refs <- mapM (const (newIORef [])) [0 .. n - 1]
handles <-
mapM
( \m -> do
let ref = refs !! fromIntegral m
h evt = do
modifyIORef' ref (pairOf evt :)
pure Continue
subscribe store (memberConfigAll "cg-all" m n h)
)
[0 .. n - 1]
let collectedCount = sum <$> mapM (fmap length . readIORef) refs
waitUntil 15_000_000 (fmap (>= total) collectedCount)
mapM_ cancel handles
collected <- mapM readIORef refs
let allPositions = sort (concatMap (map snd) collected)
allPositions `shouldBe` [1 .. fromIntegral total]
mapM_ (assertPerStreamAscending . reverse) collected
it "resumes member 2 from its own (name, member) checkpoint" $
withTestStore $ \store -> do
let nStreams = 60
streams = ["rz-" <> T.pack (show i) | i <- [1 .. nStreams]]
seed store streams 1
waitForPublisher store (GlobalPosition (fromIntegral nStreams))
-- Member 2's slice positions, in order, straight from the EP-1 partition
-- SQL — the deterministic ground truth for what member 2 should receive.
sliceV <-
runStmtP store $
Session.statement
(0 :: Int64, "rz" :: Text, 2 :: Int32, 4 :: Int32, 100000 :: Int32)
SQL.readCategoryForwardConsumerGroupStmt
let slice = map (snd . pairOf) (V.toList sliceV)
length slice `shouldSatisfy` (>= 4)
-- Run 1: member 2 stops after 2 events; checkpoint (rz-sub, 2) = slice!!1.
run1 <- collectStopAfter store (memberConfig "rz-sub" "rz" 2 4) 2
run1 `shouldBe` take 2 slice
-- A competing, much higher checkpoint for a DIFFERENT member under the
-- SAME name. If checkpoints were keyed by name only, member 2's restart
-- would resume from here and skip its events; member-keyed checkpoints
-- must ignore it.
runStmtP store $
Session.statement
("rz-sub" :: Text, 0 :: Int32, 10_000_000 :: Int64)
SQL.saveCheckpointMemberStmt
-- Run 2: member 2 restarts and must resume from its OWN checkpoint
-- (slice!!1), delivering the next two member-2 events.
run2 <- collectStopAfter store (memberConfig "rz-sub" "rz" 2 4) 2
run2 `shouldBe` take 2 (drop 2 slice)
it "lifecycle events carry the consumer-group member context (GroupMember 2 4)" $ do
ref <- newIORef ([] :: [KirokuEvent])
let evtHandler e = modifyIORef' ref (e :)
isStartedMember2 ev = case ev of
KirokuEventSubscriptionStarted (SubscriptionName "obs-sub") _ (GroupMember 2 4) -> True
_ -> False
withTestStoreSettings (\s -> s & #eventHandler .~ Just evtHandler) $ \store -> do
handle <- subscribe store (memberConfig "obs-sub" "obs" 2 4 (\_ -> pure Continue))
waitUntil 5_000_000 (any isStartedMember2 <$> readIORef ref)
cancel handle
evts <- readIORef ref
any isStartedMember2 evts `shouldBe` True
it "consumerGroupGuard fails fast when another holder holds the (name, member) lock" $ do
r <- Pg.withCached $ \db -> do
let cs = Pg.connectionString db
withStore (defaultConnectionSettings cs) $ \store -> do
-- Holder: a dedicated connection takes the SESSION-level advisory
-- lock for the same key the worker's guard probes, namely
-- hashtextextended("guard-sub:3", 0). It is held until release.
eConn <- Connection.acquire (Conn.connectionString cs)
conn <- either (\e -> error ("guard holder connect failed: " <> show e)) pure eConn
let holdStmt :: Statement Text ()
holdStmt =
preparable
"SELECT pg_advisory_lock(hashtextextended($1, 0))"
(E.param (E.nonNullable E.text))
D.noResult
lockRes <- Connection.use conn (Session.statement "guard-sub:3" holdStmt)
either (\e -> error ("guard holder lock failed: " <> show e)) pure lockRes
let cfg =
(defaultSubscriptionConfig (SubscriptionName "guard-sub") (Category (CategoryName "guardcat")) (\_ -> pure Continue))
{ consumerGroup = Just (ConsumerGroup{member = 3, size = 4})
, consumerGroupGuard = True
}
handle <- subscribe store cfg
res <- waitWithTimeout 5_000_000 handle
Connection.release conn
case res of
Right (Left e)
| Just (ConsumerGroupGuardConflict (SubscriptionName "guard-sub") 3) <- fromException e ->
pure ()
other ->
expectationFailure ("expected ConsumerGroupGuardConflict, got: " <> show other)
either (\e -> expectationFailure ("ephemeral pg failed: " <> show e)) pure r
it "subscriptionStream forwards the consumer-group field (member 0 of 2 sees its slice)" $
withTestStore $ \store -> do
let nStreams = 20
perStream = 2
total = nStreams * perStream
streams = ["bridge-" <> T.pack (show i) | i <- [1 .. nStreams]]
seed store streams perStream
waitForPublisher store (GlobalPosition (fromIntegral total))
sliceV <-
runStmtP store $
Session.statement
(0 :: Int64, "bridge" :: Text, 0 :: Int32, 2 :: Int32, 100000 :: Int32)
SQL.readCategoryForwardConsumerGroupStmt
let slicePos = sort (map (snd . pairOf) (V.toList sliceV))
k = length slicePos
-- A proper, non-trivial subset of the whole category.
k `shouldSatisfy` (\x -> x > 0 && x < total)
(stream, cancelStream) <-
subscriptionStream store (memberConfig "bridge-sub" "bridge" 0 2 (\_ -> pure Continue)) 64
pulled <- Stream.toList (Stream.take k stream)
cancelStream
sort (map (snd . pairOf) pulled) `shouldBe` slicePos