packages feed

kiroku-metrics-0.1.0.0: test/Test/IntegrationSpec.hs

{-# LANGUAGE OverloadedLabels #-}

{- | Integration test: wire the collector into a real ephemeral
PostgreSQL-backed store, run a live @$all@ subscription, append events, and
assert the snapshot reflects real store activity (not just scripted inputs).

The ordering wrinkle is that 'newKirokuMetrics' needs the 'KirokuStore' but the
callbacks must be installed on 'ConnectionSettings' before 'withStore' creates
it. We resolve it with a @TVar (Maybe KirokuStore)@ the snapshot-time STM
readers consult; it is filled inside 'withStore'. (A 'TVar', not an 'IORef',
because the readers are 'STM'.)
-}
module Test.IntegrationSpec (spec) where

import Control.Concurrent.STM (
    STM,
    TVar,
    atomically,
    check,
    newTVarIO,
    orElse,
    readTVar,
    registerDelay,
    writeTVar,
 )
import Control.Lens ((&), (.~))
import Control.Monad (unless)
import Data.Aeson qualified as Aeson
import Data.Generics.Labels ()
import Data.IntMap.Strict qualified as IntMap
import Data.Map.Strict qualified as Map
import Data.Text qualified as T
import Test.Hspec

import Kiroku.Metrics (
    LifecycleCounters (..),
    MetricsSnapshot (..),
    StoreGauges (..),
    SubscriptionMetrics (..),
    metricsEventHandler,
    metricsObservationHandler,
    newKirokuMetricsWith,
    snapshotMetrics,
 )
import Kiroku.Store (
    EventData (..),
    EventType (..),
    ExpectedVersion (..),
    KirokuStore (..),
    StreamName (..),
    SubscriptionResult (..),
    SubscriptionTarget (..),
    appendToStream,
    cancel,
    defaultConnectionSettings,
    defaultSubscriptionConfig,
    runStoreIO,
    subscribe,
    wait,
    withStore,
 )
import Kiroku.Store.Subscription.EventPublisher (EventPublisher (..), publisherPosition)
import Kiroku.Store.Subscription.Types (SubscriptionName (..))
import Kiroku.Store.Types (GlobalPosition (..))
import Kiroku.Test.Postgres (withMigratedTestDatabase)

subName :: SubscriptionName
subName = SubscriptionName "metrics-it"

spec :: Spec
spec = describe "Kiroku.Metrics.Collector (integration)" $ do
    it "observes real store activity: appends, a running subscription, and lag" $
        withMigratedTestDatabase $ \connStr -> do
            -- Deferred store handle for the snapshot-time STM readers.
            storeVar <- newTVarIO Nothing
            km <-
                newKirokuMetricsWith
                    (readPosition storeVar)
                    (readSubscribers storeVar)
            let settings =
                    defaultConnectionSettings connStr
                        & #eventHandler .~ Just (metricsEventHandler km Nothing)
                        & #observationHandler .~ Just (metricsObservationHandler km Nothing)
            withStore settings $ \store -> do
                atomically (writeTVar storeVar (Just store))

                -- Subscribe to $all on an empty store: the worker goes live
                -- immediately, so the 5 events we append next flow through the
                -- live publisher path and advance publisherPosition.
                delivered <- newTVarIO (0 :: Int)
                let cfg =
                        defaultSubscriptionConfig subName AllStreams $ \_event -> do
                            atomically (modifyCount delivered)
                            pure Continue
                handle <- subscribe store cfg

                appendStoreEvents store (StreamName "metrics-it-stream") 5
                waitForCount delivered 5 10_000_000

                cancel handle
                _ <- wait handle

                snap <- snapshotMetrics km
                snap.store.globalPosition `shouldSatisfy` (>= 5)
                snap.counters.subscriptionsStarted `shouldBe` 1
                snap.counters.subscriptionsStoppedCancelled `shouldBe` 1
                snap.counters.eventsDelivered `shouldSatisfy` (>= 5)
                case Map.lookup "metrics-it" snap.subscriptions of
                    Nothing -> expectationFailure "expected subscription \"metrics-it\" in snapshot"
                    Just m -> m.lag `shouldSatisfy` (>= 0)

{- | Snapshot-time reader for the store global position, defaulting to 0 until
the store is filled in.
-}
readPosition :: TVar (Maybe KirokuStore) -> STM GlobalPosition
readPosition storeVar =
    readTVar storeVar >>= maybe (pure (GlobalPosition 0)) (publisherPosition . (.publisher))

-- | Snapshot-time reader for the active-subscriber count.
readSubscribers :: TVar (Maybe KirokuStore) -> STM Int
readSubscribers storeVar =
    readTVar storeVar
        >>= maybe (pure 0) (\s -> IntMap.size <$> readTVar (subscribers s.publisher))

modifyCount :: TVar Int -> STM ()
modifyCount v = readTVar v >>= \c -> writeTVar v (c + 1)

-- | Append @n@ trivial events to a fresh stream via the IO store interpreter.
appendStoreEvents :: KirokuStore -> StreamName -> Int -> IO ()
appendStoreEvents store stream n = do
    let events =
            [ EventData
                { eventId = Nothing
                , eventType = EventType ("E" <> T.pack (show i))
                , payload = Aeson.Null
                , metadata = Nothing
                , causationId = Nothing
                , correlationId = Nothing
                }
            | i <- [1 .. n]
            ]
    result <- runStoreIO store (appendToStream stream NoStream events)
    case result of
        Right _ -> pure ()
        Left err -> expectationFailure ("appendStoreEvents failed: " <> show err)

-- | Wait until the counter reaches @target@ or the timeout (micros) fires.
waitForCount :: TVar Int -> Int -> Int -> IO ()
waitForCount countVar target timeoutMicros = do
    timeoutVar <- registerDelay timeoutMicros
    ok <-
        atomically $
            (do c <- readTVar countVar; check (c >= target); pure True)
                `orElse` (do t <- readTVar timeoutVar; check t; pure False)
    unless ok $ do
        actual <- atomically (readTVar countVar)
        expectationFailure ("Timed out waiting for " <> show target <> ", got " <> show actual)