packages feed

kiroku-store-0.2.0.0: test/Test/CategoryIdleNoSpin.hs

{-# LANGUAGE NumericUnderscores #-}
{-# LANGUAGE OverloadedStrings #-}

{- | Regression tests for ExecPlan 37 — the live DB-driven subscription loops must
not busy-spin when idle while the store's global @$all@ position advances for
/other/ categories or partitions.

Two paths are covered:

  * Non-group @Category@ subscription
    ('Kiroku.Store.Subscription.Worker.liveLoopCategoryNotify'): an idle category
    blocks on its own per-category NOTIFY generation and does __zero__ database
    fetches while a different category receives sustained traffic. A real append
    to the subscribed category still wakes it (liveness).
  * Consumer-group member
    ('Kiroku.Store.Subscription.Worker.liveLoopDbDriven', corrected gate): an idle
    member gates on the /last observed global position/ rather than its
    per-partition cursor, so it wakes at most once per global advance — a bounded
    number of fetches — instead of the unbounded spin the original cursor-gate
    produced.

The deterministic signal is the additive
'Kiroku.Store.Observability.KirokuEventSubscriptionFetched' event, emitted once
per live DB-driven fetch. The store's @eventHandler@ counts those whose
'SubscriptionName' matches the subscriber under test; the subscription's own
handler counts deliveries. (@pg_stat_statements@ is not available in this suite —
see the plan's Decision Log.)

To confirm these specs actually pin the regression: temporarily restore the old
cursor-gated 'liveLoopDbDriven' body and route @(Nothing, Category{})@ back through
it — both idle fetch counts then explode and the assertions fail.
-}
module Test.CategoryIdleNoSpin (spec) where

import Control.Concurrent (threadDelay)
import Control.Concurrent.MVar (newEmptyMVar)
import Control.Concurrent.STM (atomically, modifyTVar', newTVarIO, readTVarIO)
import Control.Exception (bracket)
import Control.Lens ((&), (.~))
import Data.Aeson qualified as Aeson
import Data.Generics.Labels ()
import Data.Text qualified as T
import Kiroku.Store
import Kiroku.Store.Subscription.Types (ConsumerGroup (..), SubscriptionConfigM (..))
import Test.Helpers (caughtUpEventHandler, makeEvent, waitForPublisher, waitForSubscriptionLive, withTestStoreSettings)
import Test.Hspec

-- | Poll an @IO Bool@ predicate until it holds or the microsecond budget runs out.
waitUntil :: Int -> IO Bool -> IO ()
waitUntil budget act
    | budget <= 0 = pure ()
    | otherwise = do
        ok <- act
        if ok
            then pure ()
            else do
                threadDelay 20_000
                waitUntil (budget - 20_000) act

-- | Append one event to each named stream, failing the test on a store error.
appendEach :: KirokuStore -> [T.Text] -> T.Text -> IO ()
appendEach store names typ =
    mapM_
        ( \sn -> do
            r <- runStoreIO store $ appendToStream (StreamName sn) NoStream [makeEvent typ (Aeson.object [])]
            either (error . ("append failed: " <>) . show) (const (pure ())) r
        )
        names

spec :: Spec
spec = describe "live loops do not busy-spin while idle (plan 37)" $ do
    it "an idle Category subscriber does zero fetches while another category is active, then wakes on its own event" $ do
        let subName = SubscriptionName "alpha-sub"
        deliveredVar <- newTVarIO (0 :: Int)
        fetchVar <- newTVarIO (0 :: Int)
        liveBarrier <- newEmptyMVar
        let countFetch evt = case evt of
                KirokuEventSubscriptionFetched n _ _
                    | n == subName -> atomically (modifyTVar' fetchVar (+ 1))
                _ -> pure ()
            obsHandler = caughtUpEventHandler subName liveBarrier (Just countFetch)
        withTestStoreSettings (\s -> s & #eventHandler .~ Just obsHandler) $ \store -> do
            let deliver _evt = do
                    atomically (modifyTVar' deliveredVar (+ 1))
                    pure Continue
                cfg = defaultSubscriptionConfig subName (Category (CategoryName "alpha")) deliver
            bracket (subscribe store cfg) cancel $ \_handle -> do
                -- Wait until the worker reaches live mode and completes its initial
                -- post-catch-up drain (one empty fetch), so the baseline is settled.
                waitForSubscriptionLive liveBarrier
                waitUntil 5_000_000 ((>= 1) <$> readTVarIO fetchVar)
                base <- readTVarIO fetchVar

                -- Drive a DIFFERENT category hard: the global tail races far ahead of
                -- alpha's cursor. Under the old gate alpha woke per global tick and
                -- spun one empty fetch each time; under the fix it never wakes (its
                -- category generation is untouched).
                let betaCount = 20 :: Int
                    betaStreams = ["beta-" <> T.pack (show i) | i <- [1 .. betaCount]]
                appendEach store betaStreams "Beta"
                waitForPublisher store (GlobalPosition (fromIntegral betaCount))
                -- Give a (hypothetical) spinning worker ample time to accumulate fetches.
                threadDelay 500_000

                afterIdle <- readTVarIO fetchVar
                deliveredIdle <- readTVarIO deliveredVar
                (afterIdle - base) `shouldBe` 0
                deliveredIdle `shouldBe` 0

                -- Liveness: a real append to alpha must wake the worker and deliver.
                appendEach store ["alpha-1"] "Alpha"
                waitUntil 5_000_000 ((>= 1) <$> readTVarIO deliveredVar)
                deliveredFinal <- readTVarIO deliveredVar
                deliveredFinal `shouldBe` 1

    it "an idle consumer-group member does not spin while a different category advances the global position" $ do
        let subName = SubscriptionName "grp-sub"
        deliveredVar <- newTVarIO (0 :: Int)
        fetchVar <- newTVarIO (0 :: Int)
        liveBarrier <- newEmptyMVar
        let countFetch evt = case evt of
                KirokuEventSubscriptionFetched n _ _
                    | n == subName -> atomically (modifyTVar' fetchVar (+ 1))
                _ -> pure ()
            obsHandler = caughtUpEventHandler subName liveBarrier (Just countFetch)
        withTestStoreSettings (\s -> s & #eventHandler .~ Just obsHandler) $ \store -> do
            let deliver _evt = do
                    atomically (modifyTVar' deliveredVar (+ 1))
                    pure Continue
                -- Member 0 of 3 over category "grp" — which receives NO events, so
                -- this member's partition fetch is always empty. The flood lands in
                -- a different category, advancing only the global position.
                cfg =
                    (defaultSubscriptionConfig subName (Category (CategoryName "grp")) deliver)
                        { consumerGroup = Just ConsumerGroup{member = 0, size = 3}
                        }
            bracket (subscribe store cfg) cancel $ \_handle -> do
                waitForSubscriptionLive liveBarrier

                -- The corrected group loop gates BEFORE draining, so on an empty
                -- store it blocks with zero fetches until the global position moves.
                let floodCount = 20 :: Int
                    floodStreams = ["flood-" <> T.pack (show i) | i <- [1 .. floodCount]]
                appendEach store floodStreams "Flood"
                waitForPublisher store (GlobalPosition (fromIntegral floodCount))
                threadDelay 500_000

                afterIdle <- readTVarIO fetchVar
                deliveredIdle <- readTVarIO deliveredVar
                -- The corrected gate wakes at most once per observed global position
                -- (<= floodCount), bounded — NOT the unbounded busy-spin of the old
                -- cursor gate, which racks up thousands of empty fetches in 500ms.
                afterIdle `shouldSatisfy` (< 50)
                deliveredIdle `shouldBe` 0