kiroku-store-0.9.0.1: test/Test/PublisherIdleAdvance.hs
{-# LANGUAGE OverloadedStrings #-}
module Test.PublisherIdleAdvance (spec) where
import Control.Concurrent (threadDelay)
import Control.Concurrent.Async qualified as Async
import Control.Concurrent.MVar (newEmptyMVar, tryPutMVar)
import Control.Concurrent.STM (readTVarIO)
import Control.Exception (bracket)
import Control.Lens ((&), (.~), (^.))
import Control.Monad (forM, when)
import Data.Aeson qualified as Aeson
import Data.Generics.Labels ()
import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef)
import Data.IntMap.Strict qualified as IntMap
import Data.List (sort)
import Data.String (fromString)
import GHC.Stats (GCDetails (..), RTSStats (..), getRTSStats, getRTSStatsEnabled)
import Kiroku.Store
import Kiroku.Store.Subscription.EventPublisher qualified as Pub
import System.Mem (performMajorGC)
import Test.Helpers (caughtUpEventHandler, makeEvent, waitForPublisher, waitForSubscriptionLive, withTestStore, withTestStoreSettings)
import Test.Hspec
timeoutMicros :: Int
timeoutMicros = 10_000_000
within :: String -> IO a -> IO a
within label action = do
result <- Async.race (threadDelay timeoutMicros) action
case result of
Left () -> fail ("timed out waiting for " <> label)
Right a -> pure a
countingSettings :: IORef Int -> ConnectionSettings -> ConnectionSettings
countingSettings counter settings =
settings
& #storeSettings
.~ defaultStoreSettings
{ decodeHook =
Just $ \event -> do
atomicModifyIORef' counter (\n -> (n + 1, ()))
pure event
}
appendEvents :: KirokuStore -> Int -> String -> IO GlobalPosition
appendEvents store n prefix = do
results <-
traverse
( \i ->
runStoreIO store $
appendToStream
(StreamName (fromString (prefix <> "-" <> show i)))
NoStream
[makeEvent (fromString ("E" <> show i)) (Aeson.object [])]
)
[1 .. n]
case sequence results of
Left err -> fail ("append failed: " <> show err)
Right [] -> fail "appendEvents called with zero events"
Right xs -> pure (last xs ^. #globalPosition)
publisherSubscriberCount :: KirokuStore -> IO Int
publisherSubscriberCount store =
IntMap.size <$> readTVarIO (Pub.subscribers (store ^. #publisher))
sampleLargeObjects :: IO Integer
sampleLargeObjects = do
enabled <- getRTSStatsEnabled
when (not enabled) (fail "publisher retention test requires RTS statistics")
performMajorGC
fromIntegral . gcdetails_large_objects_bytes . gc <$> getRTSStats
spec :: Spec
spec = describe "publisher idle advance" $ do
it "does not retain append results while advancing with no queue subscribers" $ do
withTestStore $ \store -> do
let blockSize :: Int
blockSize = 2_000
blocks :: Int
blocks = 6
event = makeEvent "Retention" (Aeson.object [])
appendOne = do
result <- runStoreIO store $ appendToStream (StreamName "pubidle-retention") AnyVersion [event]
case result of
Left err -> fail ("append failed: " <> show err)
Right _ -> pure ()
samples <- forM [1 .. blocks] $ \_ -> do
sequence_ (replicate blockSize appendOne)
-- Allow the notification-driven publisher to finish its tail
-- query without reading (and thereby forcing) its position.
threadDelay 100_000
sampleLargeObjects
publisherSubscriberCount store `shouldReturn` 0
case samples of
firstSample : _ -> do
let growth = last samples - firstSample
when (growth >= 3 * 1024 * 1024) $
expectationFailure ("large-object bytes grew by " <> show growth <> "; samples: " <> show samples)
[] -> expectationFailure "publisher retention test collected no heap samples"
it "advances lastPublished without decoding any rows when no subscriber is registered" $ do
counter <- newIORef 0
withTestStoreSettings (countingSettings counter) $ \store -> do
tailPos <- appendEvents store 25 "pubidle-empty"
waitForPublisher store tailPos
readIORef counter `shouldReturn` 0
it "does not fetch full rows while only a category subscriber exists" $ do
counter <- newIORef 0
caughtUp <- newEmptyMVar
delivered <- newEmptyMVar
let subName = SubscriptionName "pubidle-category"
observe = caughtUpEventHandler subName caughtUp Nothing
handler event = do
case event ^. #eventType of
EventType "Wake" -> () <$ tryPutMVar delivered ()
_ -> pure ()
pure Continue
tweak settings =
countingSettings counter settings
& #eventHandler .~ Just observe
withTestStoreSettings tweak $ \store ->
bracket
(subscribe store (defaultSubscriptionConfig subName (Category (CategoryName "pubidlea")) handler))
cancel
$ \_handle -> do
within "category subscription live" (waitForSubscriptionLive caughtUp)
countBefore <- readIORef counter
tailPos <- appendEvents store 30 "pubidleb"
waitForPublisher store tailPos
countAfter <- readIORef counter
(countAfter - countBefore) `shouldBe` 0
publisherSubscriberCount store `shouldReturn` 0
Right wakePos <-
runStoreIO store $
appendToStream
(StreamName "pubidlea-1")
NoStream
[makeEvent "Wake" (Aeson.object [])]
waitForPublisher store (wakePos ^. #globalPosition)
within "category wake event" (waitForSubscriptionLive delivered)
it "switches from cheap advance to all-stream delivery without gaps" $ do
counter <- newIORef 0
caughtUp <- newEmptyMVar
seenRef <- newIORef ([] :: [GlobalPosition])
seenEnough <- newEmptyMVar
let subName = SubscriptionName "pubidle-transition"
observe = caughtUpEventHandler subName caughtUp Nothing
handler event = do
let pos = event ^. #globalPosition
count <-
atomicModifyIORef' seenRef $ \old ->
let new = pos : old
in (new, length new)
when (count >= 35) (() <$ tryPutMVar seenEnough ())
pure Continue
tweak settings =
countingSettings counter settings
& #eventHandler .~ Just observe
withTestStoreSettings tweak $ \store -> do
tailBefore <- appendEvents store 20 "pubidle-before"
waitForPublisher store tailBefore
bracket
(subscribe store (defaultSubscriptionConfig subName AllStreams handler))
cancel
$ \_handle -> do
within "all-stream subscription live" (waitForSubscriptionLive caughtUp)
tailAfter <- appendEvents store 15 "pubidle-after"
waitForPublisher store tailAfter
within "all-stream events" (waitForSubscriptionLive seenEnough)
seen <- sort <$> readIORef seenRef
seen `shouldBe` fmap GlobalPosition [1 .. 35]