shibuya-core-0.11.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,
pauseProcessor,
resumeProcessor,
isProcessorPaused,
ControlOutcome (..),
PauseState (..),
-- * 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.Async qualified as Async
import Control.Concurrent.NQE.Supervisor qualified as NQE
import Control.Concurrent.STM
( STM,
atomically,
check,
newEmptyTMVarIO,
newTVarIO,
orElse,
putTMVar,
readTMVar,
readTVar,
registerDelay,
retry,
throwSTM,
writeTVar,
)
import Control.Exception qualified as IOException
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 OpenTelemetry.Context qualified as TraceContext
import OpenTelemetry.Context.ThreadLocal qualified as TraceContext
import OpenTelemetry.Trace.Core qualified as OTel
import Shibuya.Adapter (Adapter (..))
import Shibuya.Batch
import Shibuya.Core.Error (ConfigError (..), HandlerError (..), PolicyError (..), RuntimeError (..))
import Shibuya.Core.Metrics
( MetricsMap,
ProcessorId (..),
ProcessorMetrics (..),
)
import Shibuya.Core.Metrics qualified as Metrics
import Shibuya.Internal.App
( AppHandle (..),
OwnershipFailure (..),
QueueProcessor (..),
acquireOwned,
mkBatchProcessor,
mkProcessor,
)
import Shibuya.Internal.Runner.Master
( ControlOutcome (..),
Master,
PauseState (..),
drainAndResume,
getAllMetrics,
getAllMetricsIO,
getProcessorMetrics,
getProcessorMetricsIO,
markMasterDraining,
markMasterRunning,
startMaster,
stopMaster,
)
import Shibuya.Internal.Runner.Master qualified as Master
import Shibuya.Internal.Runner.Supervised
( SupervisedProcessor (..),
runSupervisedBatchWith,
runSupervisedWith,
)
import Shibuya.Policy (Concurrency (..), OrderingPolicy (..), validatePolicy)
import Shibuya.Telemetry.Effect (Tracing, getTracer, isTracingEnabled)
import Shibuya.Telemetry.Semantic (attrShibuyaProcessorId, internalSpanArgs)
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,
-- | Optional latency and in-flight detail; defaults to basic accounting.
metricsDetail :: !Metrics.MetricsDetail
}
deriving stock (Eq, Show, Generic)
-- | 'IgnoreFailures' with an inbox of 100.
defaultAppConfig :: AppConfig
defaultAppConfig = AppConfig {strategy = IgnoreFailures, inboxSize = 100, metricsDetail = Metrics.defaultMetricsDetail}
-- | 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 config.metricsDetail master (fromIntegral config.inboxSize) namedProcessors
markMasterRunning master
enabled <- isTracingEnabled
shutdownTracer <- if enabled then Just <$> getTracer else pure Nothing
shutdownStarted <- liftIO $ newTVarIO False
shutdownResult <- liftIO newEmptyTMVarIO
pure
AppHandle
{ master = master,
processors = Map.fromList processors,
shutdownStarted,
shutdownResult,
shutdownTracer
}
)
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
| Metrics.MetricsDetailed options <- config.metricsDetail,
options.maxTrackedPartitions < 0 =
Left $ AppConfigInvalid $ InvalidMaxTrackedPartitions options.maxTrackedPartitions
| 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) =>
Metrics.MetricsDetail ->
Master ->
Natural ->
[(ProcessorId, QueueProcessor es)] ->
Eff es [(ProcessorId, (SupervisedProcessor, QueueProcessor es))]
spawnProcessors detail master inboxSize = traverse spawnOne
where
spawnOne (procId, qp) = case qp of
QueueProcessor {adapter, handler, ordering, concurrency} -> do
sp <- runSupervisedWith detail master inboxSize procId ordering concurrency adapter handler
pure (procId, (sp, qp))
BatchingProcessor {adapter, batchHandler, batchConfig, concurrency} -> do
sp <-
runSupervisedBatchWith
detail
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
drainAndResume 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.toList appHandle.processors) $ \(procId, processor) ->
Exception.trySync (traceShutdown procId (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)
traceShutdown procId action = case appHandle.shutdownTracer of
Nothing -> action
Just tracer -> withEffToIO (ConcUnlift Persistent Unlimited) $ \runInIO ->
UIO.mask $ \restore -> do
context <- TraceContext.getContext
ready <- newEmptyTMVarIO
finished <- newEmptyTMVarIO
-- SDK hooks catch SomeException, including cancellation. Keep them
-- on a separate owned thread; only the caller runs adapter effects.
-- Even if span creation swallows cancellation, it can only observe
-- the published outcome, never start adapter cleanup afterwards.
let trace = UIO.bracket (TraceContext.attachContext context) TraceContext.detachContext $ \_ ->
OTel.inSpan' tracer (procId.unProcessorId <> " shutdown") internalSpanArgs $ \sp -> do
OTel.addAttribute sp attrShibuyaProcessorId procId.unProcessorId
atomically $ putTMVar ready sp
result <- atomically $ readTMVar finished
either IOException.throwIO pure result
UIO.withAsync trace $ \worker -> do
result <- IOException.try @SomeException $ restore $ do
sp <-
atomically $
readTMVar ready `orElse` (Async.waitCatchSTM worker >>= either throwSTM (const retry))
UIO.bracket (TraceContext.attachContext $ TraceContext.insertSpan sp context) TraceContext.detachContext $ \_ ->
runInIO action
atomically $ putTMVar finished result
case result of
-- Do not wait for export after the caller has been cancelled.
-- withAsync still cancels and joins the tracing worker.
Left failure | Exception.isAsyncException failure -> IOException.throwIO failure
_ -> do
traced <- restore $ UIO.waitCatch worker
either IOException.throwIO (const $ either IOException.throwIO pure traced) result
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
-- | Pause source admissions; already admitted polls and inbox work may drain.
pauseProcessor :: (IOE :> es) => AppHandle es -> ProcessorId -> Eff es ControlOutcome
pauseProcessor app = Master.pauseProcessor app.master
-- | Resume intake. Successful state is captured by the control transaction.
resumeProcessor :: (IOE :> es) => AppHandle es -> ProcessorId -> Eff es ControlOutcome
resumeProcessor app = Master.resumeProcessor app.master
-- | Nothing for unknown, terminal, draining or uncontrollable processors.
isProcessorPaused :: (IOE :> es) => AppHandle es -> ProcessorId -> Eff es (Maybe Bool)
isProcessorPaused app = Master.isProcessorPaused app.master