shibuya-core-0.10.0.0: src/Shibuya/App.hs
-- | Application entry point for running Shibuya queue processors.
module Shibuya.App
( -- * Running Processors
runApp,
AppConfig (..),
defaultAppConfig,
QueueProcessor (..),
mkProcessor,
mkBatchProcessor,
AppHandle,
-- * AppHandle Operations
getAppMetrics,
getAppMaster,
stopApp,
stopAppGracefully,
waitApp,
-- * Shutdown Configuration
ShutdownConfig (..),
defaultShutdownConfig,
-- * Supervision Strategy
SupervisionStrategy (..),
-- * Errors
AppError (..),
Master,
getAllMetrics,
getAllMetricsIO,
getProcessorMetrics,
getProcessorMetricsIO,
-- * Batch API (re-exported from "Shibuya.Batch")
module Shibuya.Batch,
-- * Re-exports
ProcessorId (..),
ProcessorMetrics (..),
)
where
import Control.Concurrent.NQE.Supervisor qualified as NQE
import Control.Concurrent.STM
( STM,
atomically,
check,
newEmptyTMVarIO,
newTVarIO,
orElse,
putTMVar,
readTMVar,
readTVar,
registerDelay,
writeTVar,
)
import Control.Monad (forM, forM_, void)
import Data.Bifunctor (first)
import Data.Foldable (traverse_)
import Data.Map.Strict qualified as Map
import Data.Set qualified as Set
import Data.Text qualified as Text
import Data.Time.Clock (NominalDiffTime)
import Effectful (Eff, IOE, Limit (..), Persistence (..), UnliftStrategy (..), liftIO, withEffToIO, (:>))
import Effectful.Exception qualified as Exception
import GHC.Generics (Generic)
import Numeric.Natural (Natural)
import Shibuya.Adapter (Adapter (..))
import Shibuya.Batch
import Shibuya.Core.Error (ConfigError (..), HandlerError (..), PolicyError (..), RuntimeError (..))
import Shibuya.Core.Metrics
( MetricsMap,
ProcessorId (..),
ProcessorMetrics (..),
)
import Shibuya.Internal.App
( AppHandle (..),
OwnershipFailure (..),
QueueProcessor (..),
acquireOwned,
mkBatchProcessor,
mkProcessor,
)
import Shibuya.Internal.Runner.Master
( Master,
getAllMetrics,
getAllMetricsIO,
getProcessorMetrics,
getProcessorMetricsIO,
markMasterDraining,
markMasterRunning,
markProcessorDraining,
startMaster,
stopMaster,
)
import Shibuya.Internal.Runner.Supervised
( SupervisedProcessor (..),
runSupervised,
runSupervisedBatch,
)
import Shibuya.Policy (Concurrency (..), OrderingPolicy (..), validatePolicy)
import Shibuya.Telemetry.Effect (Tracing)
import UnliftIO (SomeException, displayException)
import UnliftIO qualified as UIO
--------------------------------------------------------------------------------
-- Supervision Strategy
--------------------------------------------------------------------------------
-- | Supervision strategy for processor failures.
--
-- This is Shibuya's own type that maps to NQE's supervision strategies,
-- decoupling users from the NQE library.
data SupervisionStrategy
= -- | Ignore all child exits, keep running.
-- Failed processors are marked as Failed in metrics but don't affect others.
IgnoreFailures
| -- | Stop all processors if any fails.
-- A single processor failure triggers shutdown of all processors.
-- Graceful exits, including finite streams completing and handlers returning
-- @AckHalt@, do not stop sibling processors.
StopAllOnFailure
deriving stock (Eq, Show, Generic)
-- | Convert Shibuya's strategy type to NQE's internal type.
toNQEStrategy :: SupervisionStrategy -> NQE.Strategy
toNQEStrategy = \case
IgnoreFailures -> NQE.IgnoreAll
StopAllOnFailure -> NQE.IgnoreGraceful
--------------------------------------------------------------------------------
-- Shutdown Configuration
--------------------------------------------------------------------------------
-- | Configuration for graceful shutdown behavior.
data ShutdownConfig = ShutdownConfig
{ -- | Maximum time to wait for in-flight messages to drain.
-- After this timeout, remaining processors are forcefully stopped.
-- Default: 30 seconds.
drainTimeout :: !NominalDiffTime,
-- | Maximum time for adapter shutdown plus graceful draining before
-- forced supervisor stop begins.
-- Default: 60 seconds.
totalShutdownTimeout :: !NominalDiffTime
}
deriving stock (Eq, Show, Generic)
-- | Default shutdown configuration with a 30 second drain timeout and a
-- 60 second bound on the graceful shutdown phase.
defaultShutdownConfig :: ShutdownConfig
defaultShutdownConfig = ShutdownConfig {drainTimeout = 30, totalShutdownTimeout = 60}
--------------------------------------------------------------------------------
-- Errors
--------------------------------------------------------------------------------
-- | Application errors.
-- Uses structured error types from Shibuya.Core.Error.
data AppError
= -- | Invalid policy configuration
AppPolicyError !PolicyError
| -- | Handler execution error
AppHandlerError !HandlerError
| -- | Runtime error
AppRuntimeError !RuntimeError
| -- | Invalid batch configuration for a 'BatchingProcessor'
AppBatchConfigError !BatchConfigError
| -- | Invalid application configuration
AppConfigInvalid !ConfigError
deriving stock (Eq, Show)
-- | Configuration for @runApp@.
data AppConfig = AppConfig
{ -- | How processor failures affect siblings.
strategy :: !SupervisionStrategy,
-- | Bounded-inbox capacity per processor (backpressure). Must be >= 1.
inboxSize :: !Int
}
deriving stock (Eq, Show, Generic)
-- | 'IgnoreFailures' with an inbox of 100.
defaultAppConfig :: AppConfig
defaultAppConfig = AppConfig {strategy = IgnoreFailures, inboxSize = 100}
-- | Run queue processors concurrently under NQE supervision.
--
-- Each processor runs independently. Returns immediately with a handle
-- for introspection and control.
--
-- Example:
--
-- @
-- result <- runApp defaultAppConfig
-- [ ("orders", QueueProcessor ordersAdapter ordersHandler)
-- , ("events", QueueProcessor eventsAdapter eventsHandler)
-- ]
-- @
runApp ::
(IOE :> es, Tracing :> es) =>
-- | Application configuration
AppConfig ->
-- | Named processors
[(ProcessorId, QueueProcessor es)] ->
Eff es (Either AppError (AppHandle es))
runApp config namedProcessors =
-- Validate all policies (and batch configs) first
case validateAppConfig config *> validateUniqueProcessorIds namedProcessors *> validateAllPolicies namedProcessors of
Left err -> pure $ Left err
Right () -> do
let nqeStrategy = toNQEStrategy config.strategy
startupResult <-
acquireOwned
(startMaster nqeStrategy)
stopMaster
( \master -> do
processors <- spawnProcessors master (fromIntegral config.inboxSize) namedProcessors
markMasterRunning master
shutdownStarted <- liftIO $ newTVarIO False
shutdownResult <- liftIO newEmptyTMVarIO
pure
AppHandle
{ master = master,
processors = Map.fromList processors,
shutdownStarted,
shutdownResult
}
)
case startupResult of
Right appHandle -> pure $ Right appHandle
Left (OwnerAcquisitionFailed failure) -> startupFailure failure
Left (OwnedActionFailed failure cleanupFailure) ->
startupFailureWithCleanup failure cleanupFailure
where
startupFailure failure
| Exception.isAsyncException failure = Exception.throwIO failure
| otherwise =
pure $ Left $ AppRuntimeError $ SupervisorFailed $ Text.pack $ displayException failure
startupFailureWithCleanup failure cleanupFailure
| Exception.isAsyncException failure = Exception.throwIO failure
| otherwise =
let cleanupSuffix = case cleanupFailure of
Nothing -> ""
Just cleanupException ->
"; supervisor cleanup also failed: " <> Text.pack (displayException cleanupException)
in pure $
Left $
AppRuntimeError $
SupervisorFailed $
Text.pack (displayException failure) <> cleanupSuffix
-- | Validate app configuration before starting any processor.
validateAppConfig :: AppConfig -> Either AppError ()
validateAppConfig config
| config.inboxSize < 1 = Left $ AppConfigInvalid $ InvalidInboxSize config.inboxSize
| otherwise = Right ()
-- | Reject duplicate processor identifiers before the master or any adapter is
-- acquired. Keeping this separate from handle construction prevents a live
-- processor from being silently discarded by 'Map.fromList'.
validateUniqueProcessorIds :: [(ProcessorId, QueueProcessor es)] -> Either AppError ()
validateUniqueProcessorIds = go Set.empty
where
go _ [] = Right ()
go seen ((pid, _) : rest)
| pid `Set.member` seen = Left $ AppConfigInvalid $ DuplicateProcessorId pid
| otherwise = go (Set.insert pid seen) rest
-- | Validate all processor policies (and batch configs) before starting.
validateAllPolicies :: [(ProcessorId, QueueProcessor es)] -> Either AppError ()
validateAllPolicies = traverse_ validateOne
where
validateOne (_, qp) = case qp of
QueueProcessor {ordering, concurrency} ->
first AppPolicyError (validatePolicy ordering concurrency)
BatchingProcessor {ordering, concurrency, batchConfig} -> do
first AppPolicyError (validatePolicy ordering concurrency)
validateBatchOrdering ordering concurrency
first AppBatchConfigError (validateBatchConfig batchConfig)
validateBatchOrdering PartitionedInOrder (Ahead _) =
Left $
AppPolicyError $
InvalidPolicyCombo
"PartitionedInOrder with Ahead/Async is supported only for QueueProcessor: batching processors schedule by BatchKey, not by Envelope.partition"
validateBatchOrdering PartitionedInOrder (Async _) =
Left $
AppPolicyError $
InvalidPolicyCombo
"PartitionedInOrder with Ahead/Async is supported only for QueueProcessor: batching processors schedule by BatchKey, not by Envelope.partition"
validateBatchOrdering _ _ = Right ()
-- | Spawn all processors under supervision.
spawnProcessors ::
(IOE :> es, Tracing :> es) =>
Master ->
Natural ->
[(ProcessorId, QueueProcessor es)] ->
Eff es [(ProcessorId, (SupervisedProcessor, QueueProcessor es))]
spawnProcessors master inboxSize = traverse spawnOne
where
spawnOne (procId, qp) = case qp of
QueueProcessor {adapter, handler, ordering, concurrency} -> do
sp <- runSupervised master inboxSize procId ordering concurrency adapter handler
pure (procId, (sp, qp))
BatchingProcessor {adapter, batchHandler, batchConfig, concurrency} -> do
sp <-
runSupervisedBatch
master
inboxSize
procId
concurrency
batchConfig
adapter
batchHandler
pure (procId, (sp, qp))
--------------------------------------------------------------------------------
-- AppHandle Operations
--------------------------------------------------------------------------------
-- | Get metrics for all processors.
getAppMetrics :: (IOE :> es) => AppHandle es -> Eff es MetricsMap
getAppMetrics appHandle = getAllMetrics appHandle.master
-- | Get the master handle for direct access.
-- This is useful for integrating with the metrics server.
getAppMaster :: AppHandle es -> Master
getAppMaster appHandle = appHandle.master
-- | Gracefully stop all processors with default configuration.
-- Uses 'defaultShutdownConfig' (30 second drain timeout, 60 second graceful-phase bound).
-- For custom timeout, use 'stopAppGracefully'.
stopApp :: (IOE :> es) => AppHandle es -> Eff es ()
stopApp = void . stopAppGracefully defaultShutdownConfig
-- | Gracefully stop all processors with configurable drain and total timeout.
--
-- Shutdown sequence:
-- 1. Signal all adapters to stop producing (close source streams)
-- 2. Wait for processors to drain in-flight messages (with timeout)
-- 3. Force stop any remaining processors after timeout
-- 4. Stop the master coordinator
--
-- Returns whether all processors drained cleanly (True) or were forced (False).
stopAppGracefully :: (IOE :> es) => ShutdownConfig -> AppHandle es -> Eff es Bool
stopAppGracefully config appHandle =
Exception.mask $ \restore -> do
isLeader <-
liftIO $
atomically $ do
started <- readTVar appHandle.shutdownStarted
if started
then pure False
else writeTVar appHandle.shutdownStarted True >> pure True
if isLeader
then do
result <- Exception.try @SomeException (restore performShutdown)
liftIO $ atomically $ putTMVar appHandle.shutdownResult result
either Exception.throwIO pure result
else do
result <- restore $ liftIO $ atomically $ readTMVar appHandle.shutdownResult
either Exception.throwIO pure result
where
performShutdown = Exception.mask $ \restore -> do
let totalTimeoutMicros = nominalToMicros config.totalShutdownTimeout
outcome <-
Exception.try $
restore $
withEffToIO (ConcUnlift Persistent Unlimited) $ \runInIO ->
UIO.timeout totalTimeoutMicros (runInIO shutdownAndDrain)
-- Master cleanup is unconditional: adapter failures, external cancellation,
-- drain cancellation, and the total deadline all converge here.
stopOutcome <- Exception.try @SomeException (stopMaster appHandle.master)
case outcome of
Left (primaryFailure :: SomeException) -> Exception.throwIO primaryFailure
Right Nothing -> finishStopOutcome stopOutcome False
Right (Just drained) -> finishStopOutcome stopOutcome drained
shutdownAndDrain = do
markMasterDraining appHandle.master
forM_ (Map.keys appHandle.processors) $
markProcessorDraining appHandle.master
-- Catch only synchronous adapter failures. External cancellation and the
-- total timeout must abort this phase so the master is force-stopped.
shutdownResults <-
forM (Map.elems appHandle.processors) $ \processor ->
Exception.trySync (shutdownAdapter processor)
case [failure | Left failure <- shutdownResults] of
firstFailure : _ -> Exception.throwIO firstFailure
[] -> do
let drainTimeoutMicros = nominalToMicros config.drainTimeout
liftIO $ waitForDrainWithTimeout drainTimeoutMicros (Map.elems appHandle.processors)
shutdownAdapter (_, qp) = case qp of
QueueProcessor {adapter} -> adapter.shutdown
BatchingProcessor {adapter} -> adapter.shutdown
finishStopOutcome (Left stopFailure) _ = Exception.throwIO stopFailure
finishStopOutcome (Right ()) drained = pure drained
nominalToMicros timeout = max 0 (floor (timeout * 1_000_000))
-- | Wait for all processors to be done, with timeout.
-- Returns True if all drained cleanly, False if timeout occurred.
-- Note: Requires -threaded RTS for registerDelay to work properly.
waitForDrainWithTimeout :: Int -> [(SupervisedProcessor, a)] -> IO Bool
waitForDrainWithTimeout timeoutMicros processors = do
-- Create a timeout TVar that becomes True after the deadline
timeoutVar <- registerDelay timeoutMicros
-- Wait for either all done or timeout
atomically $
(allDone processors >> pure True)
`orElse` (readTVar timeoutVar >>= check >> pure False)
where
allDone :: [(SupervisedProcessor, a)] -> STM ()
allDone procs = forM_ procs $ \(sp, _) -> readTVar sp.done >>= check
-- | Wait for all processors to complete.
-- For infinite streams, this will block forever.
-- Use 'stopApp' to gracefully terminate.
--
-- Uses STM to block efficiently until all processors are done,
-- rather than polling.
waitApp :: (IOE :> es) => AppHandle es -> Eff es ()
waitApp appHandle = liftIO $ atomically $ do
-- Block until all done TVars are True
forM_ (Map.elems appHandle.processors) $ \(sp, _) ->
readTVar sp.done >>= check