packages feed

kiroku-store-0.3.0.0: 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 (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 Kiroku.Store
import Kiroku.Store.Subscription.EventPublisher qualified as Pub
import Test.Helpers (caughtUpEventHandler, makeEvent, waitForPublisher, waitForSubscriptionLive, 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))

spec :: Spec
spec = describe "publisher idle advance" $ do
    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)
                    before <- readIORef counter
                    tailPos <- appendEvents store 30 "pubidleb"
                    waitForPublisher store tailPos
                    after <- readIORef counter
                    (after - before) `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]