packages feed

shibuya-kiroku-adapter-0.4.0.0: test/Main.hs

module Main where

import Control.Concurrent (threadDelay)
import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar)
import Control.Concurrent.STM (atomically, newTVarIO, readTVar, registerDelay, writeTVar)
import Control.Concurrent.STM qualified as STM
import Control.Exception qualified as E
import Control.Lens ((&), (.~), (^.))
import Control.Monad (unless)
import Control.Monad.IO.Class (liftIO)
import Data.Aeson (Value)
import Data.Aeson qualified as Aeson
import Data.Foldable (toList)
import Data.Generics.Labels ()
import Data.HashMap.Strict qualified as HashMap
import Data.IORef (modifyIORef', newIORef, readIORef)
import Data.Int (Int32, Int64)
import Data.List (nub, sort)
import Data.Map.Strict qualified as Map
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text qualified as T
import Data.Time (UTCTime (..), fromGregorian)
import Data.UUID qualified as UUID
import Effectful (runEff)
import Hasql.Pool qualified as Pool
import Hasql.Session qualified as Session
import Kiroku.Store
import Kiroku.Store.SQL qualified as SQL
import Kiroku.Store.Subscription.Worker (withFetchBatchHookForTest)
import Kiroku.Test.Postgres (withMigratedTestDatabase, withSharedMigratedPostgres)
import OpenTelemetry.Attributes (toAttribute)
import Shibuya.Adapter (Adapter (..))
import Shibuya.Adapter.Kiroku (
    consumerGroupPolicy,
    defaultConsumerGroupConfig,
    defaultKirokuAdapterConfig,
    guardKirokuHandlerWith,
    kirokuAdapter,
    kirokuConsumerGroupProcessors,
    kirokuConsumerGroupProcessorsWith,
 )
import Shibuya.Adapter.Kiroku.Convert (KirokuEnvelopeAttrs, kirokuEnvelopeAttrs, toEnvelope)
import Shibuya.App (
    ProcessorId (..),
    QueueProcessor (..),
    defaultAppConfig,
    getAppMetrics,
    mkProcessor,
    runApp,
    stopApp,
    stopAppGracefully,
 )
import Shibuya.App qualified as Shibuya
import Shibuya.Core.Ack (AckDecision (..), DeadLetterReason (..))
import Shibuya.Core.Ack qualified as Ack
import Shibuya.Core.Error (PolicyError (..))
import Shibuya.Core.Ingested (Message (..))
import Shibuya.Core.Metrics (ProcessorState (..))
import Shibuya.Core.Types (Attempt (..), Envelope (..))
import Shibuya.Policy (Concurrency (..), OrderingPolicy (..))
import Shibuya.Telemetry.Effect (runTracingNoop)
import Streamly.Data.Fold qualified as Fold
import Streamly.Data.Stream qualified as Stream
import System.Timeout (timeout)
import Test.Hspec

main :: IO ()
main = withSharedMigratedPostgres $ hspec $ do
    describe "toEnvelope" $ do
        it "copies W3C trace metadata into Shibuya trace headers" $ do
            let traceparent :: Text
                traceparent = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
                tracestate :: Text
                tracestate = "rojo=00f067aa0ba902b7"
                Envelope{traceContext} =
                    toEnvelope
                        sampleEnvelopeAttrs
                        ( makeRecordedEvent
                            ( Just $
                                Aeson.object
                                    [ "traceparent" Aeson..= traceparent
                                    , "tracestate" Aeson..= tracestate
                                    , "other" Aeson..= ("preserved" :: Text)
                                    ]
                            )
                        )

            traceContext
                `shouldBe` Just
                    [ ("traceparent", "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01")
                    , ("tracestate", "rojo=00f067aa0ba902b7")
                    ]

        it "omits trace headers when traceparent is absent or not a string" $ do
            let Envelope{traceContext = missingTraceparent} =
                    toEnvelope sampleEnvelopeAttrs (makeRecordedEvent (Just (Aeson.object ["tracestate" Aeson..= ("state" :: Text)])))
                Envelope{traceContext = nonStringTraceparent} =
                    toEnvelope sampleEnvelopeAttrs (makeRecordedEvent (Just (Aeson.object ["traceparent" Aeson..= Aeson.Number 1])))

            missingTraceparent `shouldBe` Nothing
            nonStringTraceparent `shouldBe` Nothing

        it "does not surface broker headers (headers is Nothing), even with trace metadata" $ do
            let Envelope{headers = noMeta} =
                    toEnvelope sampleEnvelopeAttrs (makeRecordedEvent Nothing)
                Envelope{headers = withTraceMeta} =
                    toEnvelope
                        sampleEnvelopeAttrs
                        (makeRecordedEvent (Just (Aeson.object ["traceparent" Aeson..= ("00-abc-def-01" :: Text)])))

            noMeta `shouldBe` Nothing
            withTraceMeta `shouldBe` Nothing

        it "stamps kiroku identity attributes for a non-grouped subscription (EP-5 M2)" $ do
            let attrs = kirokuEnvelopeAttrs "orders-proj" Nothing
                Envelope{attributes} = toEnvelope attrs (makeRecordedEvent Nothing)
            -- makeRecordedEvent: eventType "TraceEvent", globalPosition 1.
            HashMap.lookup "kiroku.subscription.name" attributes
                `shouldBe` Just (toAttribute ("orders-proj" :: Text))
            HashMap.lookup "messaging.system" attributes
                `shouldBe` Just (toAttribute ("kiroku" :: Text))
            HashMap.lookup "messaging.destination.name" attributes
                `shouldBe` Just (toAttribute ("orders-proj" :: Text))
            HashMap.lookup "kiroku.event.type" attributes
                `shouldBe` Just (toAttribute ("TraceEvent" :: Text))
            HashMap.lookup "kiroku.event.global_position" attributes
                `shouldBe` Just (toAttribute (1 :: Int64))
            -- No member key for a non-grouped subscription.
            HashMap.lookup "kiroku.consumer_group.member" attributes `shouldBe` Nothing

        it "stamps the consumer-group member attribute for a grouped subscription (EP-5 M2)" $ do
            let attrs = kirokuEnvelopeAttrs "orders-proj" (Just 2)
                Envelope{attributes} = toEnvelope attrs (makeRecordedEvent Nothing)
            HashMap.lookup "kiroku.subscription.name" attributes
                `shouldBe` Just (toAttribute ("orders-proj" :: Text))
            HashMap.lookup "kiroku.consumer_group.member" attributes
                `shouldBe` Just (toAttribute (2 :: Int64))

    describe "consumer group policy" $ do
        it "accepts Serial member concurrency as (PartitionedInOrder, Serial)" $
            consumerGroupPolicy Serial `shouldBe` Right (PartitionedInOrder, Serial)

        it "rejects Ahead member concurrency with a PolicyError" $
            consumerGroupPolicy (Ahead 4)
                `shouldBe` Left (InvalidPolicyCombo "StrictInOrder requires Serial concurrency")

        it "rejects Async member concurrency with a PolicyError" $
            consumerGroupPolicy (Async 4)
                `shouldBe` Left (InvalidPolicyCombo "StrictInOrder requires Serial concurrency")

    around withTestStore $ do
        describe "kirokuAdapter" $ do
            it "delivers only matching event types when an eventTypeFilter is set (EP-43)" $ \store -> do
                -- A, B, A, B, A on one stream (global positions 1..5). The adapter
                -- is filtered to type A; the Shibuya handler must see only the As.
                let events =
                        [ makeEvent "A" (Aeson.object [])
                        , makeEvent "B" (Aeson.object [])
                        , makeEvent "A" (Aeson.object [])
                        , makeEvent "B" (Aeson.object [])
                        , makeEvent "A" (Aeson.object [])
                        ]
                Right _ <- runStoreIO store $ appendToStream (StreamName "etf-adapter-1") NoStream events
                ref <- newIORef ([] :: [RecordedEvent])
                countVar <- newTVarIO (0 :: Int)
                runEff $ runTracingNoop $ do
                    adapter <-
                        kirokuAdapter store $
                            defaultKirokuAdapterConfig (SubscriptionName "etf-adapter") AllStreams
                                & #eventTypeFilter .~ OnlyEventTypes (Set.fromList [EventType "A"])
                    let handler ingested = do
                            liftIO $ do
                                modifyIORef' ref (envelopePayload ingested :)
                                atomically $ do
                                    c <- readTVar countVar
                                    writeTVar countVar (c + 1)
                            pure AckOk
                    res <- runApp defaultAppConfig [(ProcessorId "etf", mkProcessor adapter handler)]
                    case res of
                        Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)
                        Right appHandle -> do
                            liftIO $ waitForCount countVar 3 10_000_000
                            stopApp appHandle

                collected <- readIORef ref
                -- Only the three A events reached the handler; no Bs.
                map (^. #eventType) collected `shouldBe` replicate 3 (EventType "A")
                map globalPos (reverse collected) `shouldBe` [1, 3, 5]

            it "delivers only matching types across a filtered consumer group (EP-43)" $ \store -> do
                -- 20 streams in category "etfg", each [A, B]; A at odd global
                -- positions 1,3,..,39. A size-4 group filtered to type A must
                -- deliver exactly the 20 As, disjoint and complete.
                let streams = [0 .. 19 :: Int]
                mapM_
                    ( \i -> do
                        let sn = StreamName ("etfg-" <> T.pack (show i))
                        Right _ <-
                            runStoreIO store $
                                appendToStream sn NoStream [makeEvent "A" (Aeson.object []), makeEvent "B" (Aeson.object [])]
                        pure ()
                    )
                    streams
                ref <- newIORef ([] :: [RecordedEvent])
                countVar <- newTVarIO (0 :: Int)
                runEff $ runTracingNoop $ do
                    let cfg =
                            defaultConsumerGroupConfig (SubscriptionName "etfg") (Category (CategoryName "etfg")) 4
                                & #eventTypeFilter .~ OnlyEventTypes (Set.fromList [EventType "A"])
                        handler ingested = do
                            liftIO $ do
                                modifyIORef' ref (envelopePayload ingested :)
                                atomically $ do
                                    c <- readTVar countVar
                                    writeTVar countVar (c + 1)
                            pure AckOk
                    res <- kirokuConsumerGroupProcessors store cfg handler
                    case res of
                        Left err -> liftIO $ expectationFailure ("kirokuConsumerGroupProcessors failed: " <> show err)
                        Right processors -> do
                            appRes <- runApp defaultAppConfig processors
                            case appRes of
                                Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)
                                Right appHandle -> do
                                    liftIO $ waitForCount countVar 20 15_000_000
                                    stopApp appHandle

                collected <- readIORef ref
                -- Every delivered event is an A (filter honored per member)...
                map (^. #eventType) collected `shouldBe` replicate 20 (EventType "A")
                -- ...and the union over members is the complete, disjoint set of A
                -- positions [1,3,..,39] — no A dropped, none delivered twice.
                sort (map globalPos collected) `shouldBe` [1, 3 .. 39]

            it "delivers only events matching an opaque selector (EP-43 follow-up)" $ \store -> do
                -- All one type "A" (so eventTypeFilter cannot distinguish them);
                -- only the payload tag {keep} differs. keep, skip, keep, skip, keep
                -- at positions 1..5. The selector admits only keep=True.
                let keepObj = Aeson.object [("keep", Aeson.Bool True)]
                    skipObj = Aeson.object [("keep", Aeson.Bool False)]
                    events =
                        [ makeEvent "A" keepObj
                        , makeEvent "A" skipObj
                        , makeEvent "A" keepObj
                        , makeEvent "A" skipObj
                        , makeEvent "A" keepObj
                        ]
                Right _ <- runStoreIO store $ appendToStream (StreamName "sel-adapter-1") NoStream events
                ref <- newIORef ([] :: [RecordedEvent])
                countVar <- newTVarIO (0 :: Int)
                runEff $ runTracingNoop $ do
                    adapter <-
                        kirokuAdapter store $
                            defaultKirokuAdapterConfig (SubscriptionName "sel-adapter") AllStreams
                                & #selector .~ Just (\e -> (e ^. #payload) == keepObj)
                    let handler ingested = do
                            liftIO $ do
                                modifyIORef' ref (envelopePayload ingested :)
                                atomically $ do
                                    c <- readTVar countVar
                                    writeTVar countVar (c + 1)
                            pure AckOk
                    res <- runApp defaultAppConfig [(ProcessorId "sel", mkProcessor adapter handler)]
                    case res of
                        Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)
                        Right appHandle -> do
                            liftIO $ waitForCount countVar 3 10_000_000
                            stopApp appHandle

                collected <- readIORef ref
                -- Only the three keep events reached the handler (positions 1,3,5);
                -- the two skip events were filtered worker-side and never delivered.
                map globalPos (reverse collected) `shouldBe` [1, 3, 5]

            it "delivers catch-up events through Shibuya pipeline" $ \store -> do
                let events = map (\i -> makeEvent ("CU" <> T.pack (show i)) (Aeson.object [])) [1 .. 10 :: Int]
                Right _ <- runStoreIO store $ appendToStream (StreamName "shibuya-catchup-1") NoStream events
                threadDelay 200_000

                ref <- newIORef ([] :: [RecordedEvent])
                countVar <- newTVarIO (0 :: Int)

                runEff $ runTracingNoop $ do
                    adapter <-
                        kirokuAdapter store $
                            defaultKirokuAdapterConfig (SubscriptionName "shibuya-catchup-test") AllStreams

                    let handler ingested = do
                            liftIO $ do
                                modifyIORef' ref (envelopePayload ingested :)
                                atomically $ do
                                    c <- readTVar countVar
                                    writeTVar countVar (c + 1)
                            pure AckOk

                    res <- runApp defaultAppConfig [(ProcessorId "catchup", mkProcessor adapter handler)]
                    case res of
                        Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)
                        Right appHandle -> do
                            liftIO $ waitForCount countVar 10 10_000_000
                            stopApp appHandle

                collected <- readIORef ref
                length collected `shouldBe` 10

            it "delivers live events through Shibuya pipeline" $ \store -> do
                ref <- newIORef ([] :: [RecordedEvent])
                countVar <- newTVarIO (0 :: Int)

                runEff $ runTracingNoop $ do
                    adapter <-
                        kirokuAdapter store $
                            defaultKirokuAdapterConfig (SubscriptionName "shibuya-live-test") AllStreams

                    let handler ingested = do
                            liftIO $ do
                                modifyIORef' ref (envelopePayload ingested :)
                                atomically $ do
                                    c <- readTVar countVar
                                    writeTVar countVar (c + 1)
                            pure AckOk

                    res <- runApp defaultAppConfig [(ProcessorId "live", mkProcessor adapter handler)]
                    case res of
                        Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)
                        Right appHandle -> do
                            liftIO $ threadDelay 200_000
                            liftIO $ do
                                let events = map (\i -> makeEvent ("Live" <> T.pack (show i)) (Aeson.object [])) [1 .. 5 :: Int]
                                Right _ <- runStoreIO store $ appendToStream (StreamName "shibuya-live-1") NoStream events
                                pure ()
                            liftIO $ waitForCount countVar 5 10_000_000
                            stopApp appHandle

                collected <- readIORef ref
                length collected `shouldBe` 5

            it "survives an append burst while the handler is paused" $ \store -> do
                firstSeen <- newEmptyMVar
                release <- newEmptyMVar
                countVar <- newTVarIO (0 :: Int)
                seenRef <- newIORef ([] :: [RecordedEvent])

                runEff $ runTracingNoop $ do
                    adapter <-
                        kirokuAdapter store $
                            defaultKirokuAdapterConfig (SubscriptionName "burst-pause-resume") AllStreams
                                & #queueCapacity .~ 1

                    let handler ingested = do
                            n <- liftIO $ do
                                modifyIORef' seenRef (envelopePayload ingested :)
                                atomically $ do
                                    c <- readTVar countVar
                                    let c' = c + 1
                                    writeTVar countVar c'
                                    pure c'
                            if n == 1
                                then liftIO $ putMVar firstSeen () >> takeMVar release
                                else pure ()
                            pure AckOk

                    res <- runApp defaultAppConfig [(ProcessorId "burst", mkProcessor adapter handler)]
                    case res of
                        Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)
                        Right appHandle -> do
                            liftIO $ do
                                let appendOne i =
                                        runStoreIO store $
                                            appendToStream
                                                (StreamName ("burst-" <> T.pack (show (i :: Int))))
                                                NoStream
                                                [makeEvent ("Burst" <> T.pack (show i)) (Aeson.object [])]
                                Right _ <- appendOne 1
                                takeMVar firstSeen
                                mapM_ (\i -> appendOne i >>= (`shouldSatisfy` either (const False) (const True))) [2 .. 40 :: Int]
                                putMVar release ()
                                waitForCount countVar 40 15_000_000
                            stopApp appHandle

                seen <- readIORef seenRef
                sort (map globalPos seen) `shouldBe` [1 .. 40]

            it "ends adapter.source cleanly after shutdown" $ \store -> do
                within "adapter source clean shutdown" $
                    runEff $
                        runTracingNoop $ do
                            adapter <- kirokuAdapter store $ defaultKirokuAdapterConfig (SubscriptionName "source-clean-end") AllStreams
                            let Adapter{source = sourceStream, shutdown = shutdownAction} = adapter
                            shutdownAction
                            Stream.fold Fold.drain sourceStream

            it "rethrows worker crashes through adapter.source" $ \store -> do
                Right _ <- runStoreIO store $ appendToStream (StreamName "source-crash-1") NoStream [makeEvent "SourceCrash" (Aeson.object [])]
                threadDelay 200_000
                let injectBoom _ _ = E.throwIO (userError "adapter source worker boom")
                result <-
                    withFetchBatchHookForTest injectBoom $
                        E.try $
                            within "adapter source worker crash" $
                                runEff $
                                    runTracingNoop $ do
                                        adapter <- kirokuAdapter store $ defaultKirokuAdapterConfig (SubscriptionName "source-crash") AllStreams
                                        let Adapter{source = sourceStream} = adapter
                                        Stream.fold Fold.drain sourceStream
                case result of
                    Left (e :: E.SomeException) -> show e `shouldContain` "adapter source worker boom"
                    Right _ -> expectationFailure "expected adapter.source to rethrow the worker exception"

            it "runs multiple category subscriptions concurrently" $ \store -> do
                Right _ <- runStoreIO store $ appendToStream (StreamName "orders-s1") NoStream [makeEvent "OrderCreated" (Aeson.object [])]
                Right _ <- runStoreIO store $ appendToStream (StreamName "orders-s2") NoStream [makeEvent "OrderShipped" (Aeson.object [])]
                Right _ <- runStoreIO store $ appendToStream (StreamName "payments-s1") NoStream [makeEvent "PaymentReceived" (Aeson.object [])]
                Right _ <- runStoreIO store $ appendToStream (StreamName "inventory-s1") NoStream [makeEvent "StockUpdated" (Aeson.object [])]
                Right _ <- runStoreIO store $ appendToStream (StreamName "inventory-s2") NoStream [makeEvent "StockDepleted" (Aeson.object [])]
                threadDelay 200_000

                ordersRef <- newIORef ([] :: [RecordedEvent])
                paymentsRef <- newIORef ([] :: [RecordedEvent])
                inventoryRef <- newIORef ([] :: [RecordedEvent])
                ordersCount <- newTVarIO (0 :: Int)
                paymentsCount <- newTVarIO (0 :: Int)
                inventoryCount <- newTVarIO (0 :: Int)

                runEff $ runTracingNoop $ do
                    let mkCatAdapter nm cat =
                            kirokuAdapter store $
                                defaultKirokuAdapterConfig (SubscriptionName nm) (Category (CategoryName cat))
                    let mkHandler ref' cVar ingested = do
                            liftIO $ do
                                modifyIORef' ref' (envelopePayload ingested :)
                                atomically $ do
                                    c <- readTVar cVar
                                    writeTVar cVar (c + 1)
                            pure AckOk

                    ordersAdapter <- mkCatAdapter "orders-proj" "orders"
                    paymentsAdapter <- mkCatAdapter "payments-proj" "payments"
                    inventoryAdapter <- mkCatAdapter "inventory-proj" "inventory"

                    res <-
                        runApp
                            defaultAppConfig
                            [ (ProcessorId "orders", mkProcessor ordersAdapter (mkHandler ordersRef ordersCount))
                            , (ProcessorId "payments", mkProcessor paymentsAdapter (mkHandler paymentsRef paymentsCount))
                            , (ProcessorId "inventory", mkProcessor inventoryAdapter (mkHandler inventoryRef inventoryCount))
                            ]
                    case res of
                        Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)
                        Right appHandle -> do
                            liftIO $ do
                                waitForCount ordersCount 2 10_000_000
                                waitForCount paymentsCount 1 10_000_000
                                waitForCount inventoryCount 2 10_000_000
                            stopApp appHandle

                ordersCollected <- readIORef ordersRef
                paymentsCollected <- readIORef paymentsRef
                inventoryCollected <- readIORef inventoryRef
                length ordersCollected `shouldBe` 2
                length paymentsCollected `shouldBe` 1
                length inventoryCollected `shouldBe` 2

            it "isolates a failing subscription from healthy ones" $ \store -> do
                Right _ <- runStoreIO store $ appendToStream (StreamName "good-s1") NoStream (map (\i -> makeEvent ("Good" <> T.pack (show i)) (Aeson.object [])) [1 .. 3 :: Int])
                Right _ <- runStoreIO store $ appendToStream (StreamName "bad-s1") NoStream [makeEvent "Bad1" (Aeson.object []), makeEvent "Bad2" (Aeson.object [])]
                Right _ <- runStoreIO store $ appendToStream (StreamName "other-s1") NoStream [makeEvent "Other1" (Aeson.object []), makeEvent "Other2" (Aeson.object [])]
                threadDelay 200_000

                goodRef <- newIORef ([] :: [RecordedEvent])
                otherRef <- newIORef ([] :: [RecordedEvent])
                goodCount <- newTVarIO (0 :: Int)
                otherCount <- newTVarIO (0 :: Int)

                runEff $ runTracingNoop $ do
                    goodAdapter <-
                        kirokuAdapter store $
                            defaultKirokuAdapterConfig (SubscriptionName "good-proj") (Category (CategoryName "good"))
                    badAdapter <-
                        kirokuAdapter store $
                            defaultKirokuAdapterConfig (SubscriptionName "bad-proj") (Category (CategoryName "bad"))
                    otherAdapter <-
                        kirokuAdapter store $
                            defaultKirokuAdapterConfig (SubscriptionName "other-proj") (Category (CategoryName "other"))

                    let goodHandler ingested = do
                            liftIO $ do
                                modifyIORef' goodRef (envelopePayload ingested :)
                                atomically $ do
                                    c <- readTVar goodCount
                                    writeTVar goodCount (c + 1)
                            pure AckOk

                    let badHandler _ingested = do
                            liftIO $ error "handler crash!"

                    let otherHandler ingested = do
                            liftIO $ do
                                modifyIORef' otherRef (envelopePayload ingested :)
                                atomically $ do
                                    c <- readTVar otherCount
                                    writeTVar otherCount (c + 1)
                            pure AckOk

                    res <-
                        runApp
                            defaultAppConfig
                            [ (ProcessorId "good", mkProcessor goodAdapter goodHandler)
                            , (ProcessorId "bad", mkProcessor badAdapter badHandler)
                            , (ProcessorId "other", mkProcessor otherAdapter otherHandler)
                            ]
                    case res of
                        Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)
                        Right appHandle -> do
                            liftIO $ do
                                waitForCount goodCount 3 10_000_000
                                waitForCount otherCount 2 10_000_000
                                threadDelay 500_000
                            metrics <- getAppMetrics appHandle
                            case Map.lookup (ProcessorId "bad") metrics of
                                Just m -> case m ^. #state of
                                    Failed _ _ -> pure ()
                                    other -> liftIO $ expectationFailure ("Expected Failed state, got: " <> show other)
                                Nothing -> liftIO $ expectationFailure "No metrics for bad processor"
                            stopApp appHandle

                goodCollected <- readIORef goodRef
                otherCollected <- readIORef otherRef
                length goodCollected `shouldBe` 3
                length otherCollected `shouldBe` 2

            it "shuts down all subscriptions coordinately" $ \store -> do
                Right _ <- runStoreIO store $ appendToStream (StreamName "shut-a") NoStream [makeEvent "A" (Aeson.object [])]
                Right _ <- runStoreIO store $ appendToStream (StreamName "shut-b") NoStream [makeEvent "B" (Aeson.object [])]
                threadDelay 200_000

                countA <- newTVarIO (0 :: Int)
                countB <- newTVarIO (0 :: Int)

                runEff $ runTracingNoop $ do
                    adapterA <-
                        kirokuAdapter store $
                            defaultKirokuAdapterConfig (SubscriptionName "shut-a-proj") (Category (CategoryName "shut"))
                    adapterB <-
                        kirokuAdapter store $
                            defaultKirokuAdapterConfig (SubscriptionName "shut-b-proj") (Category (CategoryName "shut"))

                    let handlerA _ingested = do
                            liftIO $ atomically $ do
                                c <- readTVar countA
                                writeTVar countA (c + 1)
                            pure AckOk

                    let handlerB _ingested = do
                            liftIO $ atomically $ do
                                c <- readTVar countB
                                writeTVar countB (c + 1)
                            pure AckOk

                    res <-
                        runApp
                            defaultAppConfig
                            [ (ProcessorId "a", mkProcessor adapterA handlerA)
                            , (ProcessorId "b", mkProcessor adapterB handlerB)
                            ]
                    case res of
                        Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)
                        Right appHandle -> do
                            liftIO $ do
                                waitForCount countA 2 10_000_000
                                waitForCount countB 2 10_000_000
                            drained <- stopAppGracefully Shibuya.defaultShutdownConfig appHandle
                            liftIO $ drained `shouldBe` True

        describe "ack dispositions" $ do
            it "AckRetry redelivers the same event, then AckOk advances (EP-40 M3)" $ \store -> do
                Right _ <- runStoreIO store $ appendToStream (StreamName "ackretry-1") NoStream [makeEvent "R1" (Aeson.object [])]
                threadDelay 200_000

                deliveries <- newIORef (0 :: Int)
                countVar <- newTVarIO (0 :: Int)

                runEff $ runTracingNoop $ do
                    adapter <-
                        kirokuAdapter store $
                            defaultKirokuAdapterConfig (SubscriptionName "ackretry-proj") AllStreams

                    let handler _ingested = do
                            n <- liftIO $ do
                                modifyIORef' deliveries (+ 1)
                                atomically $ do
                                    c <- readTVar countVar
                                    writeTVar countVar (c + 1)
                                    pure (c + 1)
                            -- Retry the first delivery, accept the second.
                            pure (if n == 1 then AckRetry (Ack.RetryDelay 0) else AckOk)

                    res <- runApp defaultAppConfig [(ProcessorId "ackretry", mkProcessor adapter handler)]
                    case res of
                        Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)
                        Right appHandle -> do
                            liftIO $ waitForCount countVar 2 10_000_000
                            stopApp appHandle

                -- The one event was delivered twice (initial + one retry).
                n <- readIORef deliveries
                n `shouldBe` 2
                -- It was not dead-lettered (the retry succeeded).
                dls <- readDeadLetters store "ackretry-proj"
                length dls `shouldBe` 0

            it "AckDeadLetter records the event and the next event continues (EP-40 M3)" $ \store -> do
                Right _ <- runStoreIO store $ appendToStream (StreamName "ackdl-1") NoStream [makeEvent "D1" (Aeson.object [])]
                Right _ <- runStoreIO store $ appendToStream (StreamName "ackdl-2") NoStream [makeEvent "D2" (Aeson.object [])]
                threadDelay 200_000

                okPayloads <- newIORef ([] :: [RecordedEvent])
                countVar <- newTVarIO (0 :: Int)

                runEff $ runTracingNoop $ do
                    adapter <-
                        kirokuAdapter store $
                            defaultKirokuAdapterConfig (SubscriptionName "ackdl-proj") AllStreams

                    let handler ingested = do
                            n <- liftIO $ atomically $ do
                                c <- readTVar countVar
                                writeTVar countVar (c + 1)
                                pure (c + 1)
                            -- Dead-letter the first event; accept the second.
                            if n == 1
                                then pure (AckDeadLetter (PoisonPill "poison"))
                                else do
                                    liftIO $ modifyIORef' okPayloads (envelopePayload ingested :)
                                    pure AckOk

                    res <- runApp defaultAppConfig [(ProcessorId "ackdl", mkProcessor adapter handler)]
                    case res of
                        Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)
                        Right appHandle -> do
                            liftIO $ waitForCount countVar 2 10_000_000
                            stopApp appHandle

                -- The first event (global position 1) is recorded as dead-letter.
                dls <- readDeadLetters store "ackdl-proj"
                map SQL.deadLetterGlobalPosition dls `shouldBe` [1]
                -- The second event was delivered and accepted.
                oks <- readIORef okPayloads
                map globalPos oks `shouldBe` [2]

            it "guardKirokuHandlerWith retries a transient handler exception and preserves attempts" $ \store -> do
                Right _ <- runStoreIO store $ appendToStream (StreamName "guardretry-1") NoStream [makeEvent "GR1" (Aeson.object [])]
                threadDelay 200_000

                deliveries <- newIORef (0 :: Int)
                attempts <- newIORef ([] :: [Maybe Attempt])
                countVar <- newTVarIO (0 :: Int)

                runEff $ runTracingNoop $ do
                    adapter <-
                        kirokuAdapter store $
                            defaultKirokuAdapterConfig (SubscriptionName "guardretry-proj") AllStreams

                    let throwingOnce ingested = do
                            let Message{envelope = Envelope{attempt = deliveryAttempt}} = ingested
                            n <- liftIO $ do
                                modifyIORef' attempts (deliveryAttempt :)
                                modifyIORef' deliveries (+ 1)
                                atomically $ do
                                    c <- readTVar countVar
                                    let c' = c + 1
                                    writeTVar countVar c'
                                    pure c'
                            if n == 1
                                then liftIO $ E.throwIO (userError "transient projection failure")
                                else pure AckOk
                        handler = guardKirokuHandlerWith (const (AckRetry (Ack.RetryDelay 0))) throwingOnce

                    res <- runApp defaultAppConfig [(ProcessorId "guardretry", mkProcessor adapter handler)]
                    case res of
                        Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)
                        Right appHandle -> do
                            liftIO $ waitForCount countVar 2 10_000_000
                            stopApp appHandle

                n <- readIORef deliveries
                n `shouldBe` 2
                reverse <$> readIORef attempts `shouldReturn` [Just (Attempt 0), Just (Attempt 1)]
                dls <- readDeadLetters store "guardretry-proj"
                length dls `shouldBe` 0

            it "guardKirokuHandlerWith dead-letters a persistent handler exception and continues" $ \store -> do
                Right _ <- runStoreIO store $ appendToStream (StreamName "guarddl-1") NoStream [makeEvent "GDL1" (Aeson.object [])]
                Right _ <- runStoreIO store $ appendToStream (StreamName "guarddl-2") NoStream [makeEvent "GDL2" (Aeson.object [])]
                threadDelay 200_000

                nextSeen <- newTVarIO (0 :: Int)

                runEff $ runTracingNoop $ do
                    adapter <-
                        kirokuAdapter store $
                            defaultKirokuAdapterConfig (SubscriptionName "guarddl-proj") AllStreams

                    let alwaysThrowFirst ingested =
                            if globalPos (envelopePayload ingested) == 1
                                then liftIO $ E.throwIO (userError "persistent projection failure")
                                else do
                                    liftIO $ atomically $ do
                                        c <- readTVar nextSeen
                                        writeTVar nextSeen (c + 1)
                                    pure AckOk
                        handler = guardKirokuHandlerWith (const (AckRetry (Ack.RetryDelay 0))) alwaysThrowFirst

                    res <- runApp defaultAppConfig [(ProcessorId "guarddl", mkProcessor adapter handler)]
                    case res of
                        Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)
                        Right appHandle -> do
                            liftIO $ waitForCount nextSeen 1 15_000_000
                            stopApp appHandle

                dls <- readDeadLetters store "guarddl-proj"
                map SQL.deadLetterGlobalPosition dls `shouldBe` [1]

        describe "consumer groups" $ do
            it "four-member group delivers a disjoint, complete partition of the stream" $ \store -> do
                -- 20 streams × 2 events = 40 events, global positions 1..40, category "cg".
                let streams = ["cg-" <> T.pack (show i) | i <- [1 .. 20 :: Int]]
                mapM_
                    ( \sn -> do
                        let evs = [makeEvent ("EV" <> T.pack (show k)) (Aeson.object []) | k <- [1 .. 2 :: Int]]
                        Right _ <- runStoreIO store $ appendToStream (StreamName sn) NoStream evs
                        pure ()
                    )
                    streams
                threadDelay 200_000

                -- One IORef + one TVar counter per member.
                refs <- mapM (const (newIORef ([] :: [RecordedEvent]))) [0 .. 3 :: Int]
                cvars <- mapM (const (newTVarIO (0 :: Int))) [0 .. 3 :: Int]

                runEff $ runTracingNoop $ do
                    -- Build one adapter per member index; each carries the same
                    -- subscriptionName and a distinct member of a size-4 group.
                    adapters <-
                        mapM
                            ( \m ->
                                kirokuAdapter store $
                                    defaultKirokuAdapterConfig (SubscriptionName "cg-shibuya-group") (Category (CategoryName "cg"))
                                        & #consumerGroup .~ Just (ConsumerGroup{member = m, size = 4})
                            )
                            [0, 1, 2, 3]

                    let mkHandler ref' cvar ingested = do
                            liftIO $ do
                                modifyIORef' ref' (envelopePayload ingested :)
                                atomically $ do
                                    c <- readTVar cvar
                                    writeTVar cvar (c + 1)
                            pure AckOk

                        processors =
                            [ ( ProcessorId ("cg-member-" <> T.pack (show m))
                              , mkProcessor (adapters !! m) (mkHandler (refs !! m) (cvars !! m))
                              )
                            | m <- [0 .. 3 :: Int]
                            ]

                    res <- runApp defaultAppConfig processors
                    case res of
                        Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)
                        Right appHandle -> do
                            liftIO $ waitForTotal cvars 40 15_000_000
                            stopApp appHandle

                collected <- mapM readIORef refs
                -- Disjoint + complete: the union of every member's delivered global
                -- positions is exactly [1..40] — no event dropped, none delivered twice.
                let allPositions = sort (concatMap (map globalPos) collected)
                allPositions `shouldBe` [1 .. 40]
                -- No member starved: 20 streams over 4 members gives each ≥ 1 stream.
                mapM_ (\c -> length c `shouldSatisfy` (>= 1)) collected

        describe "consumer group policy" $ do
            it "rejects an invalid member concurrency before opening any subscription" $ \store -> do
                -- A request the helper cannot honor per member returns Left without
                -- starting any worker. The (unused) store proves no subscription opens.
                result <-
                    runEff $
                        runTracingNoop $
                            kirokuConsumerGroupProcessors
                                store
                                ( defaultConsumerGroupConfig (SubscriptionName "cgp-reject") (Category (CategoryName "cgp-reject")) 4
                                    & #memberConcurrency .~ Async 4
                                )
                                ( \ingested -> do
                                    let _ = envelopePayload ingested
                                    pure AckOk
                                )
                case result of
                    Left err -> err `shouldBe` InvalidPolicyCombo "StrictInOrder requires Serial concurrency"
                    Right _ -> expectationFailure "expected Left PolicyError for Async member concurrency"

                withResult <-
                    runEff $
                        runTracingNoop $
                            kirokuConsumerGroupProcessorsWith
                                ( \_ -> do
                                    liftIO $ expectationFailure "factory should not be called for an invalid policy"
                                    pure stubAdapter
                                )
                                ( defaultConsumerGroupConfig (SubscriptionName "cgp-reject-with") (Category (CategoryName "cgp-reject-with")) 4
                                    & #memberConcurrency .~ Async 4
                                )
                                ( \ingested -> do
                                    let _ = envelopePayload ingested
                                    pure AckOk
                                )
                case withResult of
                    Left err -> err `shouldBe` InvalidPolicyCombo "StrictInOrder requires Serial concurrency"
                    Right _ -> expectationFailure "expected Left PolicyError for Async member concurrency"

            it "throws InvalidConsumerGroup for non-positive group sizes" $ \store -> do
                let handler ingested = do
                        let _ = envelopePayload ingested
                        pure AckOk
                    throwsSize n =
                        runEff
                            ( runTracingNoop $
                                kirokuConsumerGroupProcessors
                                    store
                                    (defaultConsumerGroupConfig (SubscriptionName ("cgp-invalid-" <> T.pack (show n))) AllStreams n)
                                    handler
                            )
                            `shouldThrow` ( \InvalidConsumerGroup{invalidMember = member, invalidSize = size} ->
                                                member == 0 && size == n
                                          )
                throwsSize 0
                throwsSize (-1)

            it "shuts down already-created member adapters after a later factory failure" $ \_store -> do
                shutdowns <- newIORef ([] :: [Int32])
                let sentinel = userError "member 2 failed"
                    factory m
                        | m == 2 = liftIO $ E.throwIO sentinel
                        | otherwise =
                            pure
                                Adapter
                                    { adapterName = "stub-kiroku"
                                    , source = Stream.nil
                                    , shutdown = liftIO $ modifyIORef' shutdowns (<> [m])
                                    }
                    handler ingested = do
                        let _ = envelopePayload ingested
                        pure AckOk
                result <-
                    E.try $
                        runEff $
                            runTracingNoop $
                                kirokuConsumerGroupProcessorsWith
                                    factory
                                    (defaultConsumerGroupConfig (SubscriptionName "cgp-partial-cleanup") AllStreams 3)
                                    handler
                case result of
                    Left (e :: E.SomeException) -> show e `shouldContain` "member 2 failed"
                    Right _ -> expectationFailure "expected the member factory failure to rethrow"
                readIORef shutdowns `shouldReturn` [1, 0]

            it "wraps consumer-group handlers so exceptions finalize a retry decision" $ \store -> do
                Right _ <- runStoreIO store $ appendToStream (StreamName "cgp-guard-1") NoStream [makeEvent "CGGuard" (Aeson.object [])]
                threadDelay 200_000

                countVar <- newTVarIO (0 :: Int)
                runEff $
                    runTracingNoop $ do
                        result <-
                            kirokuConsumerGroupProcessors
                                store
                                (defaultConsumerGroupConfig (SubscriptionName "cgp-guard") AllStreams 1)
                                ( \_ -> do
                                    n <- liftIO $ atomically $ do
                                        c <- readTVar countVar
                                        let c' = c + 1
                                        writeTVar countVar c'
                                        pure c'
                                    if n == 1
                                        then liftIO $ E.throwIO (userError "guard me")
                                        else pure AckOk
                                )
                        case result of
                            Left err -> liftIO $ expectationFailure ("kirokuConsumerGroupProcessors failed: " <> show err)
                            Right processors -> do
                                appRes <- runApp defaultAppConfig processors
                                case appRes of
                                    Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)
                                    Right appHandle -> do
                                        liftIO $ waitForCount countVar 2 15_000_000
                                        stopApp appHandle

            it "one call yields N PartitionedInOrder processors; members partition the stream disjointly" $ \store -> do
                -- 20 streams × 2 events = 40 events, global positions 1..40, category "cgp".
                let streams = ["cgp-" <> T.pack (show i) | i <- [1 .. 20 :: Int]]
                mapM_
                    ( \sn -> do
                        let evs = [makeEvent ("EV" <> T.pack (show k)) (Aeson.object []) | k <- [1 .. 2 :: Int]]
                        Right _ <- runStoreIO store $ appendToStream (StreamName sn) NoStream evs
                        pure ()
                    )
                    streams
                threadDelay 200_000

                -- A single shared handler records every delivery (consumer-group
                -- members all run the same handler). One counter for the whole group.
                ref <- newIORef ([] :: [RecordedEvent])
                countVar <- newTVarIO (0 :: Int)

                let cfg =
                        defaultConsumerGroupConfig
                            (SubscriptionName "cgp-shibuya-group")
                            (Category (CategoryName "cgp"))
                            4

                runEff $ runTracingNoop $ do
                    let handler ingested = do
                            liftIO $ do
                                modifyIORef' ref (envelopePayload ingested :)
                                atomically $ do
                                    c <- readTVar countVar
                                    writeTVar countVar (c + 1)
                            pure AckOk

                    -- One call replaces the manual `mapM mkMemberAdapter [0..3]` wiring.
                    res <- kirokuConsumerGroupProcessors store cfg handler
                    case res of
                        Left err -> liftIO $ expectationFailure ("kirokuConsumerGroupProcessors failed: " <> show err)
                        Right processors -> do
                            -- The group is presented as N processors, each pinned to the
                            -- group-level PartitionedInOrder contract + per-member Serial.
                            liftIO $ length processors `shouldBe` 4
                            let policies = map (\(_, QueueProcessor _ _ ord conc) -> (ord, conc)) processors
                            liftIO $ policies `shouldBe` replicate 4 (PartitionedInOrder, Serial)
                            -- The member index is readable off the ProcessorId.
                            let pids = map (\(ProcessorId p, _) -> p) processors
                            liftIO $
                                pids
                                    `shouldBe` ["cgp-shibuya-group-member-" <> T.pack (show m) | m <- [0 .. 3 :: Int]]

                            appRes <- runApp defaultAppConfig processors
                            case appRes of
                                Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)
                                Right appHandle -> do
                                    liftIO $ waitForCount countVar 40 15_000_000
                                    stopApp appHandle

                collected <- readIORef ref
                -- Disjoint + complete through the helper: the union of every member's
                -- delivered global positions is exactly [1..40] — no event dropped, none
                -- delivered twice. No duplicates ⇒ the member partitions are disjoint;
                -- ==[1..40] ⇒ the union is the complete source. This is the same
                -- disjoint-complete property MasterPlan 4 used, now asserted through the
                -- single-call helper and the Shibuya pipeline.
                sort (map globalPos collected) `shouldBe` [1 .. 40]
                -- Every one of the 20 originating streams was covered.
                length (nub (map origStreamId collected)) `shouldBe` 20

-- Helpers

envelopePayload :: Message es RecordedEvent -> RecordedEvent
envelopePayload ing = let Message{envelope = env} = ing in env ^. #payload

stubAdapter :: Adapter es RecordedEvent
stubAdapter =
    Adapter
        { adapterName = "stub-kiroku"
        , source = Stream.nil
        , shutdown = pure ()
        }

-- | Read the dead letters recorded for a non-group subscription (member 0).
readDeadLetters :: KirokuStore -> Text -> IO [SQL.DeadLetterRecord]
readDeadLetters store subName = do
    result <- Pool.use (store ^. #pool) (Session.statement (subName, 0 :: Int32) SQL.readDeadLettersStmt)
    case result of
        Left err -> error ("readDeadLetters failed: " <> show err)
        Right v -> pure (toList v)

-- | The raw global position of a recorded event (for disjoint/complete checks).
globalPos :: RecordedEvent -> Int64
globalPos e = case e ^. #globalPosition of GlobalPosition p -> p

-- | The originating stream's surrogate id (for stream-coverage checks).
origStreamId :: RecordedEvent -> Int64
origStreamId e = case e ^. #originalStreamId of StreamId p -> p

-- | Wait until the sum of all TVar counts reaches the target or the timeout fires.
waitForTotal :: [STM.TVar Int] -> Int -> Int -> IO ()
waitForTotal vars target timeoutMicros = do
    timeoutVar <- registerDelay timeoutMicros
    result <-
        atomically $
            ( do
                total <- sum <$> mapM readTVar vars
                STM.check (total >= target)
                pure True
            )
                `STM.orElse` ( do
                                t <- readTVar timeoutVar
                                STM.check t
                                pure False
                             )
    unless result $ do
        actual <- atomically $ sum <$> mapM readTVar vars
        expectationFailure ("Timed out waiting for total " <> show target <> ", got " <> show actual)

waitForCount :: STM.TVar Int -> Int -> Int -> IO ()
waitForCount countVar target timeoutMicros = do
    timeoutVar <- registerDelay timeoutMicros
    result <-
        atomically $
            ( do
                c <- readTVar countVar
                STM.check (c >= target)
                pure True
            )
                `STM.orElse` ( do
                                t <- readTVar timeoutVar
                                STM.check t
                                pure False
                             )
    unless result $ do
        actual <- atomically $ readTVar countVar
        expectationFailure ("Timed out waiting for count " <> show target <> ", got " <> show actual)

within :: String -> IO a -> IO a
within label action = do
    result <- timeout 10_000_000 action
    case result of
        Nothing -> fail ("Timed out waiting for " <> label)
        Just a -> pure a

makeEvent :: Text -> Value -> EventData
makeEvent typ p =
    EventData
        { eventId = Nothing
        , eventType = EventType typ
        , payload = p
        , metadata = Nothing
        , causationId = Nothing
        , correlationId = Nothing
        }

{- | A throwaway attribute source for the trace-header tests, whose assertions
do not depend on the kiroku identity attributes.
-}
sampleEnvelopeAttrs :: KirokuEnvelopeAttrs
sampleEnvelopeAttrs = kirokuEnvelopeAttrs "test-sub" Nothing

makeRecordedEvent :: Maybe Value -> RecordedEvent
makeRecordedEvent meta =
    RecordedEvent
        { eventId = EventId UUID.nil
        , eventType = EventType "TraceEvent"
        , streamVersion = StreamVersion 1
        , globalPosition = GlobalPosition 1
        , originalStreamId = StreamId 1
        , originalVersion = StreamVersion 1
        , payload = Aeson.object []
        , metadata = meta
        , causationId = Nothing
        , correlationId = Nothing
        , createdAt = UTCTime (fromGregorian 2026 5 16) 0
        }

withTestStore :: (KirokuStore -> IO ()) -> IO ()
withTestStore action =
    withMigratedTestDatabase $ \connStr ->
        withStore (defaultConnectionSettings connStr) action