shibuya-core-0.10.0.0: src/Shibuya/Internal/Runner/Master.hs
-- | __Internal module.__ Exposed for the test suite and benchmarks only.
-- No PVP guarantees: anything here may change or disappear in any release.
-- Application authors should import "Shibuya" instead.
--
-- Master handle - owns the shared supervisor and metrics registry for queue processors.
-- Provides supervision, metrics collection, and control API.
--
-- Architecture:
-- - Holds a Supervisor for managing child processors
-- - Maintains TVar MetricsMap for O(1) metrics access
-- - Processors register their metrics TVars with the Master
module Shibuya.Internal.Runner.Master
( -- * Master Handle
Master (..),
MasterState (..),
-- * Starting the Master
startMaster,
stopMaster,
-- * Introspection
getAllMetrics,
getAllMetricsIO,
getProcessorMetrics,
getProcessorMetricsIO,
MasterPhase (..),
getMasterPhase,
getMasterPhaseIO,
ProcessorLifecycle (..),
LifecycleSnapshot,
getLifecycleSnapshot,
getLifecycleSnapshotIO,
-- * Processor Management
registerProcessor,
unregisterProcessor,
markMasterRunning,
markMasterDraining,
markProcessorDraining,
markProcessorStopped,
markProcessorStoppedIO,
markProcessorFailed,
markProcessorFailedIO,
)
where
import Control.Concurrent.Async (asyncWithUnmask)
import Control.Concurrent.NQE.Process (Process (..), newMailbox)
import Control.Concurrent.NQE.Supervisor (Strategy (..), Supervisor)
import Control.Concurrent.NQE.Supervisor qualified as Supervisor
import Control.Concurrent.STM
( TVar,
atomically,
modifyTVar',
newTVarIO,
readTVar,
)
import Control.Exception qualified as Exception
import Data.IORef (IORef, atomicModifyIORef', atomicWriteIORef, newIORef, readIORef)
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Effectful (Eff, IOE, liftIO, (:>))
import Shibuya.Core.Metrics
( MetricsHandle,
MetricsMap,
ProcessorId,
ProcessorMetrics,
sampleMetrics,
)
import Shibuya.Core.Types (MessageId)
import Shibuya.Prelude
import UnliftIO (cancel)
-- | Master ownership and observation state.
data MasterState = MasterState
{ -- | Live metrics and retained lifecycle state share one STM ownership
-- cell. Registration can therefore publish both atomically without adding
-- another per-master TVar to the startup path.
registry :: !(TVar MasterRegistry),
-- | The master phase is sampled independently by health endpoints. Keeping
-- it in an atomic reference avoids paying for a standalone STM transaction
-- on every stop while processor registry updates remain transactional.
phaseRef :: !(IORef MasterPhase),
-- | The supervisor managing child processors
supervisor :: !Supervisor,
-- | Whether child failures should be linked into the spawning thread.
-- Derived from the supervision strategy: True for KillAll/IgnoreGraceful
-- (failure must reach the application), False for IgnoreAll/Notify.
propagateFailures :: !Bool
}
deriving (Generic)
data MasterRegistry = MasterRegistry
{ liveMetrics :: !(Map ProcessorId MetricsHandle),
lifecycles :: !LifecycleSnapshot
}
deriving (Generic)
-- | Lifecycle phase of the master itself. Processor terminal state is retained
-- separately in 'LifecycleSnapshot'.
data MasterPhase
= MasterStarting
| MasterRunning
| MasterDraining
| MasterStopped
deriving stock (Eq, Show, Generic)
-- | Internal lifecycle state retained for the configured processor set.
data ProcessorLifecycle
= LifecycleRunning
| LifecycleDraining
| LifecycleStopped
| LifecycleFailed !Text !(Maybe MessageId)
deriving stock (Eq, Show, Generic)
type LifecycleSnapshot = Map ProcessorId ProcessorLifecycle
-- | Master handle - owns the shared supervisor and metrics registry.
newtype Master = Master
{ -- | Direct access to master state
state :: MasterState
}
deriving (Generic)
-- | Start the master process.
-- Returns a handle for accessing shared application state.
-- The caller is responsible for calling stopMaster when done.
--
-- The supervisor is deliberately not linked to the calling thread, which is why
-- this assembles the 'Process' itself instead of using 'Supervisor.supervisor':
-- NQE's @process@ always links. With no children left the supervisor can only be
-- woken through its mailbox, and the mailbox is reachable solely through this
-- handle, so a link would turn a dropped handle into an 'ExceptionInLinkedThread'
-- in the caller at the next major garbage collection. Unlinked, such a supervisor
-- is simply collected. Processor failures still reach the caller, exactly once,
-- through the per-processor links installed when 'propagateFailures' is set.
startMaster :: (IOE :> es) => Strategy -> Eff es Master
startMaster strategy = liftIO $ Exception.mask_ $ do
(inbox, mailbox) <- newMailbox
-- The parent stays masked through the ownership transfer, but the long-lived
-- supervisor must run unmasked. Inheriting the parent's masking state makes
-- every supervisor cycle retain exception machinery and materially regresses
-- repeated startup/shutdown.
supAsync <- asyncWithUnmask $ \unmask ->
unmask (Supervisor.supervisorProcess strategy inbox)
-- Everything after 'async' is a non-blocking ownership transfer under the
-- mask. Cancellation is delivered only after the completed 'Master' returns
-- to 'acquireOwned', which then owns cleanup; there is no interruptible gap
-- that needs an extra exception frame here.
let sup = Process supAsync mailbox
registryVar <- newTVarIO $ MasterRegistry Map.empty Map.empty
phaseRef <- newIORef MasterStarting
let propagate = case strategy of
KillAll -> True
IgnoreGraceful -> True
IgnoreAll -> False
Notify _ -> False
pure Master {state = MasterState registryVar phaseRef sup propagate}
-- | Stop the master and all child processors.
-- Cancels the supervisor, which cancels all children via NQE's stopAll.
stopMaster :: (IOE :> es) => Master -> Eff es ()
stopMaster master = liftIO $ do
atomicWriteIORef master.state.phaseRef MasterStopped
cancel (getProcessAsync master.state.supervisor)
-- | Get metrics for all processors.
getAllMetrics :: (IOE :> es) => Master -> Eff es MetricsMap
getAllMetrics = liftIO . getAllMetricsIO
-- | Get metrics for all processors (IO version for web servers).
getAllMetricsIO :: Master -> IO MetricsMap
getAllMetricsIO master = do
registry <- atomically $ readTVar master.state.registry
traverse sampleMetrics registry.liveMetrics
-- | Get metrics for a specific processor.
getProcessorMetrics :: (IOE :> es) => Master -> ProcessorId -> Eff es (Maybe ProcessorMetrics)
getProcessorMetrics master = liftIO . getProcessorMetricsIO master
-- | Get metrics for a specific processor (IO version for web servers).
getProcessorMetricsIO :: Master -> ProcessorId -> IO (Maybe ProcessorMetrics)
getProcessorMetricsIO master pid = do
registry <- atomically $ readTVar master.state.registry
traverse sampleMetrics (Map.lookup pid registry.liveMetrics)
-- | Read the master lifecycle phase.
getMasterPhase :: (IOE :> es) => Master -> Eff es MasterPhase
getMasterPhase = liftIO . getMasterPhaseIO
-- | IO variant for health integrations.
getMasterPhaseIO :: Master -> IO MasterPhase
getMasterPhaseIO = readIORef . (.state.phaseRef)
-- | Read the retained processor lifecycle snapshot.
getLifecycleSnapshot :: (IOE :> es) => Master -> Eff es LifecycleSnapshot
getLifecycleSnapshot = liftIO . getLifecycleSnapshotIO
-- | IO variant for metrics and health integrations.
getLifecycleSnapshotIO :: Master -> IO LifecycleSnapshot
getLifecycleSnapshotIO master = (.lifecycles) <$> atomically (readTVar master.state.registry)
-- | Register a processor with the master.
-- The processor should call this with its metrics handle.
registerProcessor :: (IOE :> es) => Master -> ProcessorId -> MetricsHandle -> Eff es ()
registerProcessor master pid metricsHandle =
liftIO $
atomically $
modifyTVar' master.state.registry $ \registry ->
registry
{ liveMetrics = Map.insert pid metricsHandle registry.liveMetrics,
lifecycles = Map.insert pid LifecycleRunning registry.lifecycles
}
-- | Unregister a processor from the master.
unregisterProcessor :: (IOE :> es) => Master -> ProcessorId -> Eff es ()
unregisterProcessor master pid =
liftIO $
atomically $
modifyTVar' master.state.registry $ \registry ->
registry {liveMetrics = Map.delete pid registry.liveMetrics}
markMasterRunning :: (IOE :> es) => Master -> Eff es ()
markMasterRunning master = liftIO $ advanceMasterPhase master MasterRunning
markMasterDraining :: (IOE :> es) => Master -> Eff es ()
markMasterDraining master = liftIO $ advanceMasterPhase master MasterDraining
advanceMasterPhase :: Master -> MasterPhase -> IO ()
advanceMasterPhase master next =
atomicModifyIORef' master.state.phaseRef $ \current ->
(case current of MasterStopped -> MasterStopped; _ -> next, ())
markProcessorDraining :: (IOE :> es) => Master -> ProcessorId -> Eff es ()
markProcessorDraining master pid =
liftIO $
atomically $
modifyTVar' master.state.registry $ \registry ->
registry
{ lifecycles =
Map.adjust
(\case LifecycleRunning -> LifecycleDraining; terminal -> terminal)
pid
registry.lifecycles
}
markProcessorStopped :: (IOE :> es) => Master -> ProcessorId -> Eff es ()
markProcessorStopped master = liftIO . markProcessorStoppedIO master
markProcessorStoppedIO :: Master -> ProcessorId -> IO ()
markProcessorStoppedIO master pid =
atomically $
modifyTVar' master.state.registry $ \registry ->
registry
{ lifecycles =
Map.adjust
(\case LifecycleFailed failure messageId -> LifecycleFailed failure messageId; _ -> LifecycleStopped)
pid
registry.lifecycles
}
markProcessorFailed :: (IOE :> es) => Master -> ProcessorId -> Text -> Maybe MessageId -> Eff es ()
markProcessorFailed master pid failure messageId =
liftIO $ markProcessorFailedIO master pid failure messageId
markProcessorFailedIO :: Master -> ProcessorId -> Text -> Maybe MessageId -> IO ()
markProcessorFailedIO master pid failure messageId =
atomically $
modifyTVar' master.state.registry $ \registry ->
registry
{ lifecycles = Map.insert pid (LifecycleFailed failure messageId) registry.lifecycles
}