packages feed

kiroku-store-0.4.0.0: test/Test/SubscriptionCheckpointInventory.hs

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

module Test.SubscriptionCheckpointInventory (spec) where

import Control.Concurrent.MVar (MVar, newEmptyMVar, putMVar, takeMVar)
import Control.Lens ((&), (.~), (^.))
import Data.Aeson qualified as Aeson
import Data.Generics.Labels ()
import Data.Int (Int32, Int64)
import Data.Map.Strict qualified as Map
import Data.Text (Text)
import Data.Text qualified as T
import Data.Time.Clock (getCurrentTime)
import Data.Vector qualified as V
import Effectful (runEff)
import Effectful.Error.Static (runErrorNoCallStack)
import Hasql.Pool qualified as Pool
import Hasql.Session qualified as Session
import Kiroku.Store
import Kiroku.Store.SQL qualified as SQL
import System.Timeout (timeout)
import Test.Helpers (caughtUpEventHandler, insertDeadLetterForEvent, makeEvent, waitForPublisher, waitForSubscriptionLive, waitWithTimeout, withTestStore, withTestStoreSettings)
import Test.Hspec

spec :: Spec
spec = describe "SubscriptionCheckpointInventory" $ do
    it "returns position zero and no rows for an empty migrated store" $
        withTestStore $ \store -> do
            SubscriptionCheckpointInventory captured rows <- readInventory store
            captured `shouldBe` GlobalPosition 0
            rows `shouldBe` V.empty

    it "runs through the resource-backed Store interpreter" $
        withTestStore $ \store -> do
            result <-
                runEff
                    . runErrorNoCallStack @StoreError
                    . runKirokuStoreWith store
                    . runStoreResource
                    $ subscriptionCheckpointInventory
            result `shouldBe` Right (SubscriptionCheckpointInventory (GlobalPosition 0) V.empty)

    it "returns the exact store position and a member-zero checkpoint" $
        withTestStore $ \store -> do
            appendEvents store "inventory-single" 3
            saveCheckpoint store "single" 0 2

            SubscriptionCheckpointInventory captured rows <- readInventory store
            captured `shouldBe` GlobalPosition 3
            checkpointKeys rows `shouldBe` [("single", 0, 2)]
            now <- getCurrentTime
            case V.toList rows of
                [SubscriptionCheckpoint _ _ _ updatedAt] -> updatedAt `shouldSatisfy` (<= now)
                _ -> expectationFailure "expected exactly one checkpoint"

    it "returns multiple names and members in deterministic key order" $
        withTestStore $ \store -> do
            appendEvents store "inventory-many" 20
            saveCheckpoint store "zeta" 2 7
            saveCheckpoint store "alpha" 10 3
            saveCheckpoint store "alpha" 2 5

            SubscriptionCheckpointInventory captured rows <- readInventory store
            captured `shouldBe` GlobalPosition 20
            checkpointKeys rows
                `shouldBe` [ ("alpha", 2, 5)
                           , ("alpha", 10, 3)
                           , ("zeta", 2, 7)
                           ]

    it "preserves monotonic checkpoints and observes later commits on a fresh read" $
        withTestStore $ \store -> do
            appendEvents store "inventory-monotonic" 10
            saveCheckpoint store "monotonic" 0 8
            saveCheckpoint store "monotonic" 0 4

            first <- readInventory store
            inventoryKeys first `shouldBe` [("monotonic", 0, 8)]

            saveCheckpoint store "monotonic" 0 9
            second <- readInventory store
            inventoryKeys second `shouldBe` [("monotonic", 0, 9)]

    it "retains a durable row after the worker stops and leaves live state" $
        withTestStore $ \store -> do
            appendEvents store "inventory-stopped" 1
            waitForPublisher store (GlobalPosition 1)
            let name = SubscriptionName "stopped"
            handle <- subscribe store (defaultSubscriptionConfig name AllStreams (\_ -> pure Stop))
            waitClean handle

            states <- subscriptionStates store
            Map.member (name, 0) states `shouldBe` False
            inventory <- readInventory store
            inventoryKeys inventory `shouldBe` [("stopped", 0, 1)]

    it "does not expose in-flight live handler progress before checkpoint commit" $ do
        caughtUp <- newEmptyMVar
        enteredHandler <- newEmptyMVar
        releaseHandler <- newEmptyMVar
        let name = SubscriptionName "in-flight"
            observe = caughtUpEventHandler name caughtUp Nothing
            handler _ = do
                putMVar enteredHandler ()
                takeMVar releaseHandler
                pure Stop
            config = defaultSubscriptionConfig name AllStreams handler
        withTestStoreSettings (& #eventHandler .~ Just observe) $ \store -> do
            appendEvents store "inventory-live" 1
            saveCheckpoint store "in-flight" 0 1
            waitForPublisher store (GlobalPosition 1)
            handle <- subscribe store config
            waitForSubscriptionLive caughtUp

            appendEventsExisting store "inventory-live" 1
            waitForMVar "live handler did not receive the event" enteredHandler
            beforeCommit <- readInventory store
            inventoryKeys beforeCommit `shouldBe` [("in-flight", 0, 1)]

            putMVar releaseHandler ()
            waitClean handle
            afterCommit <- readInventory store
            inventoryKeys afterCommit `shouldBe` [("in-flight", 0, 2)]

    it "observes the checkpoint advanced by a dead-letter transaction" $
        withTestStore $ \store -> do
            appendEvents store "inventory-dead-letter" 1
            Right events <- runStoreIO store $ readAllForward (GlobalPosition 0) 10
            let event = V.head events
            insertDeadLetterForEvent store "dead-lettered" event

            inventory <- readInventory store
            inventoryKeys inventory `shouldBe` [("dead-lettered", 0, 1)]

    it "captures a head at or beyond every normally written checkpoint" $
        withTestStore $ \store -> do
            appendEvents store "inventory-bounds" 6
            saveCheckpoint store "bounds-a" 0 2
            saveCheckpoint store "bounds-b" 1 6

            SubscriptionCheckpointInventory (GlobalPosition captured) rows <- readInventory store
            let positions = [position | SubscriptionCheckpoint _ _ (GlobalPosition position) _ <- V.toList rows]
            positions `shouldSatisfy` all (<= captured)

readInventory :: KirokuStore -> IO SubscriptionCheckpointInventory
readInventory store = do
    result <- runStoreIO store subscriptionCheckpointInventory
    case result of
        Left err -> error ("subscriptionCheckpointInventory failed: " <> show err)
        Right inventory -> pure inventory

saveCheckpoint :: KirokuStore -> Text -> Int32 -> Int64 -> IO ()
saveCheckpoint store name member position = do
    result <- Pool.use (store ^. #pool) $ Session.statement (name, member, position) SQL.saveCheckpointMemberStmt
    case result of
        Left err -> error ("saveCheckpoint failed: " <> show err)
        Right () -> pure ()

appendEvents :: KirokuStore -> Text -> Int -> IO ()
appendEvents store stream count = do
    let events = [makeEvent ("E" <> T.pack (show i)) (Aeson.object []) | i <- [1 .. count]]
    result <- runStoreIO store $ appendToStream (StreamName stream) NoStream events
    case result of
        Left err -> error ("appendEvents failed: " <> show err)
        Right _ -> pure ()

appendEventsExisting :: KirokuStore -> Text -> Int -> IO ()
appendEventsExisting store stream count = do
    let events = [makeEvent ("Live" <> T.pack (show i)) (Aeson.object []) | i <- [1 .. count]]
    result <- runStoreIO store $ appendToStream (StreamName stream) StreamExists events
    case result of
        Left err -> error ("appendEventsExisting failed: " <> show err)
        Right _ -> pure ()

waitClean :: SubscriptionHandle -> IO ()
waitClean handle = do
    result <- waitWithTimeout 20_000_000 handle
    case result of
        Left message -> expectationFailure message
        Right (Left err) -> expectationFailure ("subscription failed: " <> show err)
        Right (Right ()) -> pure ()

waitForMVar :: String -> MVar () -> IO ()
waitForMVar failureMessage var = do
    result <- timeout 5_000_000 (takeMVar var)
    case result of
        Nothing -> expectationFailure failureMessage
        Just () -> pure ()

inventoryKeys :: SubscriptionCheckpointInventory -> [(Text, Int32, Int64)]
inventoryKeys (SubscriptionCheckpointInventory _ rows) = checkpointKeys rows

checkpointKeys :: V.Vector SubscriptionCheckpoint -> [(Text, Int32, Int64)]
checkpointKeys rows =
    [ (name, member, position)
    | SubscriptionCheckpoint (SubscriptionName name) member (GlobalPosition position) _ <- V.toList rows
    ]