packages feed

kiroku-store-0.3.0.0: test/Test/Concurrency.hs

{- | Deterministic concurrency tests for kiroku-store.

Each test spawns multiple threads that race on append paths the
single-threaded scenario suite cannot exercise. The tests target the
EP-6 M1 Concurrency Scenarios (F9–F11) and verify EP-1 F4 (the sorted
@SELECT … FOR UPDATE@ pre-pass that prevents multi-stream deadlocks)
under deliberate adversarial ordering.

Each test acquires a fresh ephemeral PostgreSQL via 'withTestStore'.
-}
module Test.Concurrency (spec) where

import Control.Concurrent.Async qualified as Async
import Control.Lens ((^.))
import Control.Monad (forM_)
import Data.Aeson qualified as Aeson
import Data.Generics.Labels ()
import Data.Int (Int64)
import Data.List (sort)
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text qualified as T
import Data.UUID qualified as UUID
import Data.Vector qualified as V
import Kiroku.Store
import Test.Helpers (countEvents, makeEvent, withTestStore)
import Test.Hspec

spec :: Spec
spec = describe "kiroku-store concurrency (deterministic)" $ do
    it "many AnyVersion writers to one stream preserve stream and global order" $
        withTestStore $ \store -> do
            let writerCount = 24
                stream = StreamName "stress-one-stream"
            results <-
                Async.forConcurrently [1 .. writerCount] $ \i -> do
                    runStoreIO store $
                        appendToStream
                            stream
                            AnyVersion
                            [makeEvent ("Stress" <> T.pack (show i)) (Aeson.object [])]
            mapM_ assertRightAppend results
            Right streamEvents <- runStoreIO store $ readStreamForward stream (StreamVersion 0) 1000
            V.length streamEvents `shouldBe` writerCount
            streamVersions streamEvents `shouldBe` [1 .. fromIntegral writerCount]
            Right allEvents <- runStoreIO store $ readAllForward (GlobalPosition 0) 1000
            V.length allEvents `shouldBe` writerCount
            globalPositions allEvents `shouldBe` [1 .. fromIntegral writerCount]

    it "many AnyVersion writers to invoice-payment preserve stream and global order" $
        withTestStore $ \store -> do
            let writerCount = 32
                stream = StreamName "invoice-payment"
            results <-
                Async.forConcurrently [1 .. writerCount] $ \i -> do
                    runStoreIO store $
                        appendToStream
                            stream
                            AnyVersion
                            [makeEvent ("InvoicePayment" <> T.pack (show i)) (Aeson.object [])]
            mapM_ assertRightAppend results
            Right streamEvents <- runStoreIO store $ readStreamForward stream (StreamVersion 0) 1000
            V.length streamEvents `shouldBe` writerCount
            streamVersions streamEvents `shouldBe` [1 .. fromIntegral writerCount]
            Right allEvents <- runStoreIO store $ readAllForward (GlobalPosition 0) 1000
            V.length allEvents `shouldBe` writerCount
            globalPositions allEvents `shouldBe` [1 .. fromIntegral writerCount]
            Set.fromList (eventIds streamEvents) `shouldBe` Set.fromList (eventIds allEvents)

    it "many batched writers to different streams preserve batch-local and global order" $
        withTestStore $ \store -> do
            let streamCount = 12
                batchSize = 10
                totalEvents = streamCount * batchSize
                mkStream i = StreamName ("stress-batch-" <> T.pack (show i))
                mkBatch i =
                    [ makeEvent ("Batch" <> T.pack (show i) <> "-" <> T.pack (show j)) (Aeson.object [])
                    | j <- [1 .. batchSize]
                    ]
            results <-
                Async.forConcurrently [1 .. streamCount] $ \i -> do
                    runStoreIO store $ appendToStream (mkStream i) NoStream (mkBatch i)
            mapM_ assertRightAppend results
            forM_ [1 .. streamCount] $ \i -> do
                Right streamEvents <- runStoreIO store $ readStreamForward (mkStream i) (StreamVersion 0) 1000
                streamVersions streamEvents `shouldBe` [1 .. fromIntegral batchSize]
            Right allEvents <- runStoreIO store $ readAllForward (GlobalPosition 0) 1000
            V.length allEvents `shouldBe` totalEvents
            globalPositions allEvents `shouldBe` [1 .. fromIntegral totalEvents]

    it "large append batches return the final stream and global positions" $
        withTestStore $ \store -> do
            let stream = StreamName "stress-large-batches"
                mkBatch label n =
                    [ makeEvent (label <> "-" <> T.pack (show i)) (Aeson.object [])
                    | i <- [1 .. n]
                    ]
            Right r10 <- runStoreIO store $ appendToStream stream NoStream (mkBatch "Batch10" 10)
            (r10 ^. #streamVersion) `shouldBe` StreamVersion 10
            (r10 ^. #globalPosition) `shouldBe` GlobalPosition 10
            Right r100 <- runStoreIO store $ appendToStream stream AnyVersion (mkBatch "Batch100" 100)
            (r100 ^. #streamVersion) `shouldBe` StreamVersion 110
            (r100 ^. #globalPosition) `shouldBe` GlobalPosition 110
            Right streamEvents <- runStoreIO store $ readStreamForward stream (StreamVersion 0) 200
            streamVersions streamEvents `shouldBe` [1 .. 110]
            Right allEvents <- runStoreIO store $ readAllForward (GlobalPosition 0) 200
            globalPositions allEvents `shouldBe` [1 .. 110]

    it "overlapping appendMultiStream stress preserves all-or-nothing ordering" $
        withTestStore $ \store -> do
            let streams = [StreamName "stress-multi-a", StreamName "stress-multi-b", StreamName "stress-multi-c"]
                rotations =
                    [ streams
                    , [StreamName "stress-multi-b", StreamName "stress-multi-c", StreamName "stress-multi-a"]
                    , [StreamName "stress-multi-c", StreamName "stress-multi-a", StreamName "stress-multi-b"]
                    ]
                opsFor i =
                    [ (sn, AnyVersion, [makeEvent ("Multi" <> T.pack (show i) <> "-" <> labelStream sn) (Aeson.object [])])
                    | sn <- rotations !! (i `mod` length rotations)
                    ]
            forM_ streams $ \sn -> do
                result <- runStoreIO store $ appendToStream sn NoStream [makeEvent "init" (Aeson.object [])]
                assertRightAppend result
            results <-
                Async.forConcurrently [1 .. 9] $ \i -> do
                    runStoreIO store $ appendMultiStream (opsFor i)
            mapM_ assertRightMulti results
            forM_ streams $ \sn -> do
                Right streamEvents <- runStoreIO store $ readStreamForward sn (StreamVersion 0) 100
                streamVersions streamEvents `shouldBe` [1 .. 10]
            Right allEvents <- runStoreIO store $ readAllForward (GlobalPosition 0) 100
            V.length allEvents `shouldBe` 30
            globalPositions allEvents `shouldBe` [1 .. 30]

    it "duplicate event failure leaves touched streams and $all unchanged" $
        withTestStore $ \store -> do
            let duplicate =
                    EventId $
                        case UUID.fromString "01234567-89ab-7def-8012-34567890abce" of
                            Just u -> u
                            Nothing -> error "bad uuid"
                duplicateEvent =
                    EventData
                        { eventId = Just duplicate
                        , eventType = EventType "Duplicate"
                        , payload = Aeson.object []
                        , metadata = Nothing
                        , causationId = Nothing
                        , correlationId = Nothing
                        }
            Right _ <- runStoreIO store $ appendToStream (StreamName "rollback-seed") NoStream [duplicateEvent]
            Right _ <- runStoreIO store $ appendToStream (StreamName "rollback-a") NoStream [makeEvent "init-a" (Aeson.object [])]
            Right _ <- runStoreIO store $ appendToStream (StreamName "rollback-b") NoStream [makeEvent "init-b" (Aeson.object [])]
            beforeCount <- countEvents store
            Right beforeAll <- runStoreIO store $ readAllForward (GlobalPosition 0) 100
            result <-
                runStoreIO store $
                    appendMultiStream
                        [ (StreamName "rollback-a", AnyVersion, [duplicateEvent])
                        , (StreamName "rollback-b", AnyVersion, [makeEvent "should-not-commit" (Aeson.object [])])
                        ]
            case result of
                Left (DuplicateEvent _) -> pure ()
                other -> expectationFailure ("duplicate event should abort the multi-stream transaction, got: " <> show other)
            countEvents store `shouldReturn` beforeCount
            Right afterAll <- runStoreIO store $ readAllForward (GlobalPosition 0) 100
            globalPositions afterAll `shouldBe` globalPositions beforeAll
            Right streamA <- runStoreIO store $ readStreamForward (StreamName "rollback-a") (StreamVersion 0) 100
            Right streamB <- runStoreIO store $ readStreamForward (StreamName "rollback-b") (StreamVersion 0) 100
            streamVersions streamA `shouldBe` [1]
            streamVersions streamB `shouldBe` [1]

    -- F9 — Two concurrent appends to different streams. Both calls
    -- must succeed, the global positions must be unique and contiguous,
    -- and the test must not deadlock.
    it "two concurrent appends to different streams both succeed (F9)" $
        withTestStore $ \store -> do
            (rA, rB) <-
                Async.concurrently
                    (runStoreIO store $ appendToStream (StreamName "f9-a") NoStream [makeEvent "A" (Aeson.object [])])
                    (runStoreIO store $ appendToStream (StreamName "f9-b") NoStream [makeEvent "B" (Aeson.object [])])
            case (rA, rB) of
                (Right resA, Right resB) -> do
                    let pA = case resA ^. #globalPosition of GlobalPosition n -> n
                        pB = case resB ^. #globalPosition of GlobalPosition n -> n
                    pA `shouldNotBe` pB
                    Set.fromList [pA, pB] `shouldBe` Set.fromList [1, 2]
                other -> expectationFailure ("F9: both should succeed, got: " <> show other)

    -- F10 — Two concurrent appends to the same stream with the same
    -- ExactVersion. Exactly one must succeed; the other must fail
    -- with WrongExpectedVersion. No deadlock. Stream is pre-created
    -- because ExactVersion 0 against a non-existent stream is itself
    -- an error in kiroku (streams start at version 1).
    it "two concurrent ExactVersion appends to same stream — one wins (F10)" $
        withTestStore $ \store -> do
            Right _ <- runStoreIO store $ appendToStream (StreamName "f10") NoStream [makeEvent "Init" (Aeson.object [])]
            (r1, r2) <-
                Async.concurrently
                    (runStoreIO store $ appendToStream (StreamName "f10") (ExactVersion (StreamVersion 1)) [makeEvent "X" (Aeson.object [])])
                    (runStoreIO store $ appendToStream (StreamName "f10") (ExactVersion (StreamVersion 1)) [makeEvent "Y" (Aeson.object [])])
            case (r1, r2) of
                (Right _, Left (WrongExpectedVersion _ _ _)) -> pure ()
                (Left (WrongExpectedVersion _ _ _), Right _) -> pure ()
                other -> expectationFailure ("F10: exactly one should win with the loser returning WrongExpectedVersion, got: " <> show other)
            -- Stream must have exactly init + winner = 2 events after the race.
            Right events <- runStoreIO store $ readStreamForward (StreamName "f10") (StreamVersion 0) 100
            V.length events `shouldBe` 2

    -- F11 — Two concurrent appendMultiStream calls touching the same
    -- streams in opposite order. EP-1 F4's sorted SELECT FOR UPDATE
    -- pre-pass ensures both transactions acquire row locks in the
    -- same canonical order, preventing the classic two-resource
    -- deadlock. Without the fix, this test would intermittently fail
    -- with PostgreSQL deadlock detection (40P01).
    it "two concurrent multi-stream appends in opposite order do not deadlock (F11)" $
        withTestStore $ \store -> do
            -- Pre-create both streams so the multi-stream calls take
            -- the existing-stream path (where pre-locking matters).
            Right _ <- runStoreIO store $ appendToStream (StreamName "f11-x") NoStream [makeEvent "init-x" (Aeson.object [])]
            Right _ <- runStoreIO store $ appendToStream (StreamName "f11-y") NoStream [makeEvent "init-y" (Aeson.object [])]
            let opsXY =
                    [ (StreamName "f11-x", AnyVersion, [makeEvent "Ax" (Aeson.object [])])
                    , (StreamName "f11-y", AnyVersion, [makeEvent "Ay" (Aeson.object [])])
                    ]
                opsYX =
                    [ (StreamName "f11-y", AnyVersion, [makeEvent "By" (Aeson.object [])])
                    , (StreamName "f11-x", AnyVersion, [makeEvent "Bx" (Aeson.object [])])
                    ]
            (rA, rB) <-
                Async.concurrently
                    (runStoreIO store $ appendMultiStream opsXY)
                    (runStoreIO store $ appendMultiStream opsYX)
            case (rA, rB) of
                (Right _, Right _) -> pure ()
                other -> expectationFailure ("F11: both calls must succeed without deadlock, got: " <> show other)
            -- Each stream contains its init event plus one event from
            -- each of the two concurrent multi-stream appends (3 each).
            Right xs <- runStoreIO store $ readStreamForward (StreamName "f11-x") (StreamVersion 0) 100
            Right ys <- runStoreIO store $ readStreamForward (StreamName "f11-y") (StreamVersion 0) 100
            V.length xs `shouldBe` 3
            V.length ys `shouldBe` 3
            -- \$all has 6 events total (2 inits + 4 from concurrent calls).
            Right allEvts <- runStoreIO store $ readAllForward (GlobalPosition 0) 100
            V.length allEvts `shouldBe` 6
            let positions =
                    map
                        (\e -> case e ^. #globalPosition of GlobalPosition n -> n)
                        (V.toList allEvts)
            sort positions `shouldBe` [1, 2, 3, 4, 5, 6]

    -- EP-4 M3 — Best-effort regression for the single-stream append retry.
    -- The race can pass vacuously on fast machines, but it must never surface
    -- PostgreSQL's transient transaction SQLSTATEs (40001/40P01) to callers as
    -- UnexpectedServerError.
    it "single-stream append retry does not leak transient SQLSTATEs" $
        withTestStore $ \store -> do
            forM_ [1 .. 25 :: Int] $ \i -> do
                let a = StreamName ("retry-a-" <> T.pack (show i))
                    b = StreamName ("retry-b-" <> T.pack (show i))
                    multi =
                        appendMultiStream
                            [ (a, NoStream, [makeEvent "A" (Aeson.object [])])
                            , (b, AnyVersion, [makeEvent "B-multi" (Aeson.object [])])
                            ]
                    single =
                        appendToStream b NoStream [makeEvent "B-single" (Aeson.object [])]
                (multiResult, singleResult) <-
                    Async.concurrently
                        (runStoreIO store multi)
                        (runStoreIO store single)
                assertNoTransientLeak ("multi round " <> show i) multiResult
                assertNoTransientLeak ("single round " <> show i) singleResult

assertRightAppend :: Either StoreError AppendResult -> IO ()
assertRightAppend = \case
    Right _ -> pure ()
    Left err -> expectationFailure ("append should succeed, got: " <> show err)

assertRightMulti :: Either StoreError [AppendResult] -> IO ()
assertRightMulti = \case
    Right _ -> pure ()
    Left err -> expectationFailure ("appendMultiStream should succeed, got: " <> show err)

assertNoTransientLeak :: String -> Either StoreError a -> IO ()
assertNoTransientLeak label = \case
    Left (UnexpectedServerError code _)
        | code == "40P01" || code == "40001" ->
            expectationFailure (label <> ": transient SQLSTATE leaked as UnexpectedServerError " <> show code)
    _ ->
        pure ()

streamVersions :: V.Vector RecordedEvent -> [Int64]
streamVersions =
    map
        (\e -> case e ^. #streamVersion of StreamVersion n -> n)
        . V.toList

globalPositions :: V.Vector RecordedEvent -> [Int64]
globalPositions =
    map
        (\e -> case e ^. #globalPosition of GlobalPosition n -> n)
        . V.toList

eventIds :: V.Vector RecordedEvent -> [EventId]
eventIds =
    map (^. #eventId)
        . V.toList

labelStream :: StreamName -> Text
labelStream (StreamName name) = name