shibuya-core-0.11.0.0: test/Shibuya/Runner/PauseSpec.hs
module Shibuya.Runner.PauseSpec (spec) where
import Control.Concurrent (myThreadId, yield)
import Control.Concurrent.NQE.Process (newBoundedInbox)
import Control.Concurrent.NQE.Supervisor (Strategy (IgnoreAll))
import Control.Concurrent.STM
import Control.Monad (forM_, replicateM_, void)
import Data.Map.Strict qualified as Map
import Data.Time.Clock (getCurrentTime)
import Effectful (liftIO, runEff)
import Effectful.State.Static.Local qualified as State
import Shibuya qualified as Public
import Shibuya.Adapter (Adapter (..))
import Shibuya.Batch (BatchConfig (..), ackAllOk, defaultBatchConfig)
import Shibuya.Core.Ack (AckDecision (..), HaltReason (..))
import Shibuya.Core.AckHandle (AckHandle (..))
import Shibuya.Core.Ingested (mkIngested)
import Shibuya.Core.Metrics
import Shibuya.Core.Types (MessageId (..), mkEnvelope)
import Shibuya.Internal.Runner.Ingester (runIngesterWithMetrics)
import Shibuya.Internal.Runner.Master
import Shibuya.Internal.Runner.Pause
import Shibuya.Internal.Runner.Supervised (SupervisedProcessor (..), runSupervised)
import Shibuya.Policy (Concurrency (..), OrderingPolicy (..))
import Shibuya.Telemetry.Effect (runTracingNoop)
import Streamly.Data.Stream qualified as Stream
import Test.Hspec
import UnliftIO qualified as UIO
spec :: Spec
spec = describe "pause and resume" $ do
it "preserves source-local effects and counts only yielded messages across skipped steps" $ do
owner <- myThreadId
now <- getCurrentTime
h <- newMetricsHandle now
pause <- newPauseHandle
inbox <- newBoundedInbox 10
updates <- runEff $ State.execState (0 :: Int) $ runTracingNoop $ do
let numbers =
Stream.unfoldrM
( \n -> do
liftIO $ myThreadId >>= (`shouldBe` owner)
State.modify @Int (+ 1)
pure $ if n < 6 then Just (n, n + 1) else Nothing
)
(0 :: Int)
source =
fmap
(\n -> mkIngested (mkEnvelope (MessageId "source-local") n) (AckHandle (\_ -> pure ())))
(Stream.filter even numbers)
runIngesterWithMetrics (ProcessorId "source-local") h pause source inbox
updates `shouldBe` 7
snapshot <- sampleMetrics h
snapshot.stats.received `shouldBe` 3
it "requests nothing further when paused before source admission" $ do
now <- getCurrentTime
h <- newMetricsHandle now
pause <- newPauseHandle
requestPause pause `shouldReturn` True
polled <- newEmptyTMVarIO
inbox <- newBoundedInbox 10
let action =
runEff $
runTracingNoop $
runIngesterWithMetrics
(ProcessorId "pause-ingester")
h
pause
(Stream.repeatM (liftIO (atomically $ putTMVar polled ()) >> pure (mkIngested (mkEnvelope (MessageId "a") ()) (AckHandle (\_ -> pure ())))))
inbox
UIO.withAsync action $ \_ -> do
UIO.timeout 100_000 (atomically $ takeTMVar polled) `shouldReturn` Nothing
requestResume pause `shouldReturn` True
UIO.timeout 1_000_000 (atomically $ takeTMVar polled) `shouldReturn` Just ()
it "allows an admitted poll and in-flight work to drain, isolates siblings, and resumes before shutdown" $ do
forM_ [False, True] $ \batching -> do
polls <- newTQueueIO
releases <- newTQueueIO
starts <- newTQueueIO
finishes <- newTVarIO (0 :: Int)
stop <- newTVarIO False
handlerGate <- newEmptyTMVarIO
sibling <- newEmptyTMVarIO
runEff $ runTracingNoop $ do
let source =
Stream.unfoldrM
( \n -> do
liftIO $ atomically $ writeTQueue polls n
next <-
liftIO $
atomically $
(readTVar stop >>= check >> pure Nothing) `orElse` (Just <$> readTQueue releases)
pure $ fmap (\() -> (mkIngested (mkEnvelope (MessageId "live") ()) (AckHandle (\_ -> liftIO $ atomically $ modifyTVar' finishes (+ 1))), n + 1)) next
)
(1 :: Int)
adapter = Adapter "live" source (liftIO $ atomically $ writeTVar stop True)
work = liftIO ((atomically (writeTQueue starts ()) >> atomically (takeTMVar handlerGate))) >> pure AckOk
processor =
if batching
then Public.mkBatchProcessor adapter (\_ _ -> work >> pure ackAllOk) defaultBatchConfig {batchSize = 1}
else Public.mkProcessor adapter (\_ -> work)
siblingAdapter = Adapter "sibling" (Stream.fromList [mkIngested (mkEnvelope (MessageId "sibling") ()) (AckHandle (\_ -> pure ()))]) (pure ())
result <- Public.runApp Public.defaultAppConfig [(ProcessorId "live", processor), (ProcessorId "sibling", Public.mkProcessor siblingAdapter (\_ -> liftIO (atomically $ putTMVar sibling ()) >> pure AckOk))]
app <- either (liftIO . ioError . userError . show) pure result
liftIO $ atomically (readTQueue polls) `shouldReturn` 1
first <- Public.pauseProcessor app (ProcessorId "live")
liftIO $ first `shouldSatisfy` isAppliedPause
second <- Public.pauseProcessor app (ProcessorId "live")
liftIO $ case (first, second) of
(ControlApplied state, ControlAlreadyInState same) -> same `shouldBe` state
other -> expectationFailure (show other)
liftIO $ atomically $ writeTQueue releases ()
liftIO $ bounded (atomically $ readTQueue starts) >> bounded (atomically $ takeTMVar sibling)
during <- Public.getAppMetrics app
liftIO $ (during Map.! ProcessorId "live").state `shouldSatisfy` pausedWith 1
liftIO $ atomically $ putTMVar handlerGate ()
liftIO $ bounded $ atomically $ readTVar finishes >>= check . (== 1)
-- Completion includes the metrics decrement, which follows finalization.
liftIO $
bounded $
let await = do
m <- getProcessorMetricsIO (Public.getAppMaster app) (ProcessorId "live")
if maybe False (pausedWith 0 . (.state)) m then pure () else yield >> await
in await
liftIO $ UIO.timeout 100_000 (atomically $ readTQueue polls) `shouldReturn` Nothing
Public.resumeProcessor app (ProcessorId "live") >>= liftIO . (`shouldBe` ControlApplied IntakeRunning)
Public.resumeProcessor app (ProcessorId "live") >>= liftIO . (`shouldBe` ControlAlreadyInState IntakeRunning)
liftIO $ atomically (readTQueue polls) `shouldReturn` 2
_ <- Public.pauseProcessor app (ProcessorId "live")
drained <- Public.stopAppGracefully Public.defaultShutdownConfig app
liftIO $ drained `shouldBe` True
liftIO $ readTVarIO stop `shouldReturn` True
it "reports unknown, uncontrollable, terminal and draining outcomes atomically" $ do
master <- runEff $ startMaster IgnoreAll
UIO.finally
( do
now <- getCurrentTime
h <- newMetricsHandle now
pause <- newPauseHandle
pauseProcessorIO master (ProcessorId "unknown") `shouldReturn` ControlNotFound
runEff $ registerProcessor master (ProcessorId "fixture") h
pauseProcessorIO master (ProcessorId "fixture") `shouldReturn` ControlNotControllable
runEff $ registerProcessorWithPause master (ProcessorId "live") h (Just pause)
replicateM_ 20 $ do
UIO.mapConcurrently_ (\n -> void $ if even n then pauseProcessorIO master (ProcessorId "live") else resumeProcessorIO master (ProcessorId "live")) [1 .. 20 :: Int]
intended <- isPaused pause
sampled <- sampleMetrics h
isJustPause sampled.state `shouldBe` intended
_ <- UIO.concurrently (runEff $ drainAndResume master) (pauseProcessorIO master (ProcessorId "live"))
isPaused pause `shouldReturn` False
pauseProcessorIO master (ProcessorId "live") `shouldReturn` ControlDraining
isProcessorPausedIO master (ProcessorId "live") `shouldReturn` Nothing
markProcessorStoppedIO master (ProcessorId "live")
resumeProcessorIO master (ProcessorId "live") `shouldReturn` ControlTerminal
runEff $ unregisterProcessor master (ProcessorId "live")
pauseProcessorIO master (ProcessorId "live") `shouldReturn` ControlTerminal
)
(runEff $ stopMaster master)
it "keeps terminal halt and finalization failure above pause intent" $ do
forM_ [False, True] $ \failure -> do
started <- newEmptyTMVarIO
release <- newEmptyTMVarIO
master <- runEff $ startMaster IgnoreAll
UIO.finally
( runEff $ runTracingNoop $ do
let message = mkIngested (mkEnvelope (MessageId "terminal") ()) (AckHandle (\_ -> if failure then liftIO (ioError (userError "failure")) else pure ()))
adapter = Adapter "terminal" (Stream.fromList [message]) (pure ())
proc <-
runSupervised
master
10
(ProcessorId "terminal")
Unordered
Serial
adapter
( \_ -> do
liftIO $ (atomically (putTMVar started ()) >> atomically (takeTMVar release))
pure $ if failure then AckOk else AckHalt (HaltFatal "stop")
)
liftIO $ bounded (atomically $ takeTMVar started)
_ <- liftIO $ pauseProcessorIO master (ProcessorId "terminal")
liftIO $ atomically $ putTMVar release ()
liftIO $ bounded (atomically $ readTVar proc.done >>= check)
liftIO $ resumeProcessorIO master (ProcessorId "terminal") `shouldReturn` ControlTerminal
lifecycle <- liftIO $ getLifecycleSnapshotIO master
liftIO $
if failure
then (lifecycle Map.! ProcessorId "terminal") `shouldSatisfy` (\case LifecycleFailed {} -> True; _ -> False)
else (lifecycle Map.! ProcessorId "terminal") `shouldBe` LifecycleStopped
m <- liftIO $ sampleMetrics proc.metrics
liftIO $ m.state `shouldSatisfy` (\case Failed {} -> True; _ -> False)
)
(runEff $ stopMaster master)
isAppliedPause :: ControlOutcome -> Bool
isAppliedPause (ControlApplied (IntakePaused _)) = True
isAppliedPause _ = False
isJustPause :: ProcessorState -> Bool
isJustPause Paused {} = True
isJustPause _ = False
pausedWith :: Int -> ProcessorState -> Bool
pausedWith n (Paused info _ _) = info.inFlight == n
pausedWith _ _ = False
bounded :: IO a -> IO a
bounded action = UIO.timeout 2_000_000 action >>= maybe (expectationFailure "timed out" >> error "timeout") pure