kiroku-store-0.7.0.0: test/Test/StreamHistoryGuard.hs
{-# LANGUAGE MultilineStrings #-}
{-# LANGUAGE NumericUnderscores #-}
module Test.StreamHistoryGuard (spec) where
import Control.Concurrent (threadDelay)
import Control.Concurrent.Async qualified as Async
import Control.Lens ((^.))
import Control.Monad (forM_, when)
import Data.Aeson qualified as Aeson
import Data.Text qualified as Text
import Data.Vector qualified as Vector
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 Hasql.Transaction qualified as Tx
import Kiroku.Store
import Test.Helpers (makeEvent, withTestStore)
import Test.Hspec
spec :: Spec
spec = describe "stream history guard" $ do
it "returns exact metadata and pages with production cursor semantics" $
withTestStore $ \store -> do
let stream = StreamName "guard-metadata"
Right _ <- runStoreIO store $ appendToStream stream NoStream (events 3)
Right _ <- runStoreIO store $ setStreamTruncateBefore stream (StreamVersion 2)
result <- runTx store $ do
locked <- lockStreamHistoryForReplayTx stream
page <- readStreamForwardTx stream (StreamVersion 0) 10
pure (locked, page)
case result of
(Right info, page) -> do
info ^. #version `shouldBe` StreamVersion 3
info ^. #truncateBefore `shouldBe` StreamVersion 2
info ^. #deletedAt `shouldBe` Nothing
fmap (^. #streamVersion) (Vector.toList page)
`shouldBe` [StreamVersion 2, StreamVersion 3]
(Left unavailable, _) -> expectationFailure ("guard unexpectedly unavailable: " <> show unavailable)
it "returns typed unavailable results for missing and reserved streams" $
withTestStore $ \store -> do
missing <- runTx store (lockStreamHistoryForReplayTx (StreamName "guard-missing"))
missing `shouldBe` Left (StreamHistoryNotFound (StreamName "guard-missing"))
reserved <- runTx store (lockStreamHistoryForReplayTx (StreamName "$all"))
reserved `shouldBe` Left (StreamHistoryReserved (StreamName "$all"))
it "returns soft-deleted metadata and blocks undelete until guard completion" $
withTestStore $ \store -> do
let stream = StreamName "guard-undelete"
Right _ <- runStoreIO store $ appendToStream stream NoStream (events 1)
Right _ <- runStoreIO store $ softDeleteStream stream
assertBlockedByGuard store stream $ do
info <- runTx store (lockStreamHistoryForReplayTx stream)
info `shouldSatisfy` \case Right StreamInfo{deletedAt = Just _} -> True; _ -> False
runStoreIO store (undeleteStream stream)
it "blocks append until guard completion" $
withTestStore $ \store -> do
let stream = StreamName "guard-append"
Right _ <- runStoreIO store $ appendToStream stream NoStream (events 1)
assertBlockedByGuard store stream $
runStoreIO store (appendToStream stream AnyVersion (events 1))
it "releases the guard on rollback and lets the blocked append complete" $
withTestStore $ \store -> do
let stream = StreamName "guard-rollback"
Right _ <- runStoreIO store $ appendToStream stream NoStream (events 1)
assertBlockedByGuardEnding True store stream $
runStoreIO store (appendToStream stream AnyVersion (events 1))
it "blocks link into the guarded stream until guard completion" $
withTestStore $ \store -> do
let source = StreamName "guard-link-source"
target = StreamName "guard-link-target"
Right _ <- runStoreIO store $ appendToStream source NoStream (events 1)
Right _ <- runStoreIO store $ appendToStream target NoStream (events 1)
Right sourceRows <- runStoreIO store $ readStreamForward source (StreamVersion 0) 10
let eventId = Vector.head sourceRows ^. #eventId
assertBlockedByGuard store target $
runStoreIO store (linkToStream target [eventId])
it "blocks soft delete and logical truncate until guard completion" $
withTestStore $ \store -> do
let soft = StreamName "guard-soft-delete"
truncated = StreamName "guard-truncate"
Right _ <- runStoreIO store $ appendToStream soft NoStream (events 1)
Right _ <- runStoreIO store $ appendToStream truncated NoStream (events 2)
assertBlockedByGuard store soft $
runStoreIO store (softDeleteStream soft)
assertBlockedByGuard store truncated $
runStoreIO store (setStreamTruncateBefore truncated (StreamVersion 2))
it "blocks hard delete of the guarded origin until guard completion" $
withTestStore $ \store -> do
let stream = StreamName "guard-hard-delete"
Right _ <- runStoreIO store $ appendToStream stream NoStream (events 1)
assertBlockedByGuard store stream $
runStoreIO store (hardDeleteStream stream)
it "blocks hard delete of another origin linked into the guarded stream" $
withTestStore $ \store -> do
let origin = StreamName "guard-linked-origin"
guarded = StreamName "guard-linked-target"
Right _ <- runStoreIO store $ appendToStream origin NoStream (events 1)
Right _ <- runStoreIO store $ appendToStream guarded NoStream (events 1)
Right sourceRows <- runStoreIO store $ readStreamForward origin (StreamVersion 0) 10
Right _ <- runStoreIO store $ linkToStream guarded [Vector.head sourceRows ^. #eventId]
assertBlockedByGuard store guarded $
runStoreIO store (hardDeleteStream origin)
it "keeps opposing hard deletes and multi-stream appends deadlock-free" $
withTestStore $ \store ->
forM_ [1 .. 5 :: Int] $ \index -> do
let suffix = Text.pack (show index)
first = StreamName ("guard-race-a-" <> suffix)
second = StreamName ("guard-race-b-" <> suffix)
Right _ <- runStoreIO store $ appendToStream first NoStream (events 1)
Right _ <- runStoreIO store $ appendToStream second NoStream (events 1)
operations <-
Async.async $
Async.concurrently
(runStoreIO store $ hardDeleteStream first)
( runStoreIO store $
appendMultiStream
[ (first, AnyVersion, events 1)
, (second, AnyVersion, events 1)
]
)
_ <- waitWithin "hard-delete/multi-append race" operations
pure ()
assertBlockedByGuard :: KirokuStore -> StreamName -> IO result -> IO ()
assertBlockedByGuard = assertBlockedByGuardEnding False
assertBlockedByGuardEnding :: Bool -> KirokuStore -> StreamName -> IO result -> IO ()
assertBlockedByGuardEnding rollBack store stream mutation = do
guard <- Async.async $ runStoreIO store $ runTransaction $ do
locked <- lockStreamHistoryForReplayTx stream
_ <- Tx.statement () holdGuardStmt
when rollBack Tx.condemn
pure locked
waitForGuardPhase store 100
waiter <- Async.async mutation
threadDelay 50_000
Async.poll waiter >>= \case
Nothing -> pure ()
Just _ -> expectationFailure "mutation completed while the stream-history guard was held"
guardResult <- waitWithin "guard transaction" guard
guardResult `shouldSatisfy` \case Right (Right _) -> True; _ -> False
_ <- waitWithin "blocked mutation" waiter
pure ()
waitForGuardPhase :: KirokuStore -> Int -> IO ()
waitForGuardPhase _ 0 = expectationFailure "guard never reached its held phase"
waitForGuardPhase store attempts = do
result <- Pool.use (store ^. #pool) (Session.statement () guardActiveStmt)
case result of
Right True -> pure ()
Right False -> threadDelay 10_000 >> waitForGuardPhase store (attempts - 1)
Left err -> expectationFailure ("could not observe guard phase: " <> show err)
waitWithin :: String -> Async.Async value -> IO value
waitWithin label action = do
result <- Async.race (threadDelay 2_000_000) (Async.wait action)
case result of
Left () -> do
Async.cancel action
expectationFailure (label <> " timed out")
error "unreachable"
Right value -> pure value
runTx :: KirokuStore -> Tx.Transaction value -> IO value
runTx store transaction = do
result <- runStoreIO store (runTransaction transaction)
case result of
Left err -> expectationFailure ("guard transaction failed: " <> show err) >> error "unreachable"
Right value -> pure value
events :: Int -> [EventData]
events count =
[makeEvent "Guarded" (Aeson.object [("n", Aeson.toJSON n)]) | n <- [1 .. count]]
holdGuardStmt :: Statement () Bool
holdGuardStmt =
preparable
"SELECT pg_sleep(0.4) IS NULL"
E.noParams
(D.singleRow (D.column (D.nonNullable D.bool)))
guardActiveStmt :: Statement () Bool
guardActiveStmt =
preparable
"""
SELECT EXISTS (
SELECT 1
FROM pg_stat_activity
WHERE state = 'active'
AND query = 'SELECT pg_sleep(0.4) IS NULL'
)
"""
E.noParams
(D.singleRow (D.column (D.nonNullable D.bool)))