shibuya-metrics-0.11.0.0: src/Shibuya/Metrics/WebSocket.hs
-- | Real-time metrics with independent production and one bounded application writer.
module Shibuya.Metrics.WebSocket
( websocketApp,
WebSocketState (..),
newWebSocketState,
shutdownWebSockets,
WebSocketHooks (..),
defaultWebSocketHooks,
websocketAppWithHooks,
)
where
import Control.Concurrent.Async (race_)
import Control.Concurrent.MVar (MVar, newMVar, withMVar)
import Control.Concurrent.STM
( STM,
TVar,
atomically,
check,
modifyTVar',
newTVarIO,
orElse,
readTVar,
readTVarIO,
registerDelay,
retry,
writeTVar,
)
import Control.Exception (catch, evaluate, finally, mask, throwIO)
import Control.Monad (forever, unless, when)
import Data.Aeson (eitherDecode, encode)
import Data.ByteString qualified as BS
import Data.ByteString.Lazy qualified as LBS
import Data.Map.Strict qualified as Map
import Data.Set (Set)
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Word (Word16, Word64)
import GHC.Clock (getMonotonicTimeNSec)
import Network.WebSockets qualified as WS
import Shibuya.App (Master, getAllMetricsIO)
import Shibuya.Core.Metrics (MetricsMap, ProcessorId (..))
import Shibuya.Core.Metrics qualified as Metrics
import Shibuya.Core.Types (MessageId (..))
import Shibuya.Internal.Runner.Master (ProcessorLifecycle (..), getLifecycleSnapshotIO)
import Shibuya.Metrics.Config (MetricsServerConfig (..))
import Shibuya.Metrics.Outbound qualified as Out
import Shibuya.Metrics.Types (ClientMessage (..), ProcessorTerminalStatus (..), ServerMessage (..))
import System.Timeout (timeout)
-- | Shared connection accounting and idempotent shutdown signal.
data WebSocketState = WebSocketState
{connectionCount :: !(TVar Int), maxConnections :: !Int, shutdownRequested :: !(TVar Bool)}
newWebSocketState :: Int -> IO WebSocketState
newWebSocketState maxConns = WebSocketState <$> newTVarIO 0 <*> pure maxConns <*> newTVarIO False
-- | Stop admission and wake every producer to request an orderly, bounded flush.
shutdownWebSockets :: WebSocketState -> IO ()
shutdownWebSockets state = atomically $ writeTVar state.shutdownRequested True
data AcquireResult = Acquired | AtCapacity | ServerShuttingDown
acquireConnection :: WebSocketState -> STM AcquireResult
acquireConnection state = do
stopping <- readTVar state.shutdownRequested
count <- readTVar state.connectionCount
if stopping
then pure ServerShuttingDown
else
if count >= state.maxConnections
then pure AtCapacity
else writeTVar state.connectionCount (count + 1) >> pure Acquired
releaseConnection :: WebSocketState -> STM ()
releaseConnection state = modifyTVar' state.connectionCount (max 0 . subtract 1)
data Subscription = AllProcessors !(Set ProcessorId) | SelectedProcessors !(Set ProcessorId)
-- The lock covers subscription, sampling, ledger and admission. It never covers
-- socket IO. Only the writer pops the queue; all producers obey the close cell.
data ConnectionState = ConnectionState
{ producerLock :: !(MVar ()),
subscriptions :: !(TVar Subscription),
generation :: !(TVar Int),
lastMetrics :: !(TVar MetricsMap),
outbound :: !(TVar Out.OutboundQueue),
resyncPending :: !(TVar Bool),
closing :: !(TVar (Maybe CloseControl))
}
data CloseControl = CloseControl !Word16 !Text !Word64
newConnectionState :: IO ConnectionState
newConnectionState =
ConnectionState
<$> newMVar ()
<*> newTVarIO (AllProcessors Set.empty)
<*> newTVarIO 0
<*> newTVarIO Map.empty
<*> newTVarIO Out.empty
<*> newTVarIO False
<*> newTVarIO Nothing
-- | Instrumentation for deterministic transport tests and externally mounted
-- servers. Hooks run inside the send deadline. Production uses no-op hooks and
-- an independent interval timer; a custom wait must also observe shutdown.
data WebSocketHooks = WebSocketHooks
{ beforeWrite :: !(BS.ByteString -> IO ()),
afterReceive :: !(Out.OutboundQueue -> IO ()),
afterProduce :: !(Out.OutboundQueue -> IO ()),
waitForPush :: !(Int -> WebSocketState -> IO Bool)
}
defaultWebSocketHooks :: WebSocketHooks
defaultWebSocketHooks = WebSocketHooks (const $ pure ()) (const $ pure ()) (const $ pure ()) waitForPushOrShutdown
websocketApp :: MetricsServerConfig -> Master -> WebSocketState -> WS.ServerApp
websocketApp = websocketAppWithHooks defaultWebSocketHooks
websocketAppWithHooks :: WebSocketHooks -> MetricsServerConfig -> Master -> WebSocketState -> WS.ServerApp
websocketAppWithHooks hooks config master state pending = mask $ \restore -> do
outcome <- atomically $ acquireConnection state
case outcome of
AtCapacity -> restore $ WS.rejectRequest pending "Too many connections"
ServerShuttingDown -> restore $ WS.rejectRequest pending "Server shutting down"
Acquired ->
restore (serveConnection hooks config master state pending `catch` normalPeerClosure)
`finally` atomically (releaseConnection state)
normalPeerClosure :: WS.ConnectionException -> IO ()
normalPeerClosure = \case
WS.ConnectionClosed -> pure ()
WS.CloseRequest _ _ -> pure ()
unexpected -> throwIO unexpected
serveConnection :: WebSocketHooks -> MetricsServerConfig -> Master -> WebSocketState -> WS.PendingConnection -> IO ()
serveConnection hooks config master state pending = do
conn <- WS.acceptRequest pending
WS.withPingThread conn 30 (pure ()) $ do
cs <- newConnectionState
withMVar cs.producerLock $ \() -> subscriptionSnapshot config master cs
-- Orderly producers wait rather than returning and cancelling queued data.
-- A peer exception, writer timeout or writer completion cancels and joins both.
race_
(writerLoop hooks config cs conn)
(race_ (receiveLoop hooks config master state cs conn) (pushLoop hooks config master state cs))
limits :: MetricsServerConfig -> Out.Limits
limits config = Out.Limits config.wsMaxQueuedFrames config.wsMaxQueuedBytes
encoded :: Int -> Out.Retention -> ServerMessage -> IO Out.Frame
encoded gen keep message = do
let bytes = LBS.toStrict $ encode message
_ <- evaluate $ BS.length bytes
pure $ Out.frame gen keep bytes
-- Called under the producer lock. Encoding is forced before entering STM.
admit :: MetricsServerConfig -> ConnectionState -> [Out.Frame] -> IO Out.Outcome
admit config cs frames = do
outcome <- atomically $ do
stopped <- readTVar cs.closing
case stopped of
Just _ -> pure Out.MustClose
-- An empty terminal batch must not write an unchanged queue and wake
-- every waiting writer before the actual delta is ready.
Nothing | null frames -> pure Out.Enqueued
Nothing -> do
q <- readTVar cs.outbound
let (result, next) = Out.enqueue (limits config) frames q
writeTVar cs.outbound next
when (result == Out.DroppedUpdates) $ writeTVar cs.resyncPending True
pure result
when (outcome == Out.MustClose) $ requestClose config cs 1013 "Outbound queue capacity exceeded"
pure outcome
requestClose :: MetricsServerConfig -> ConnectionState -> Word16 -> Text -> IO ()
requestClose config cs code reason = do
now <- getMonotonicTimeNSec
atomically $ do
current <- readTVar cs.closing
case current of
Just _ -> pure ()
Nothing ->
writeTVar cs.closing $
Just $
CloseControl
code
reason
(now + fromIntegral config.wsSendTimeoutUs * 1_000)
awaitWriter :: IO a
awaitWriter = atomically retry
receiveLoop :: WebSocketHooks -> MetricsServerConfig -> Master -> WebSocketState -> ConnectionState -> WS.Connection -> IO ()
receiveLoop hooks config master state cs conn = forever $ do
msg <- WS.receiveData conn
withMVar cs.producerLock $ \() -> do
stopped <- readTVarIO cs.closing
case stopped of
Just _ -> pure ()
Nothing -> do
shuttingDown <- readTVarIO state.shutdownRequested
unless shuttingDown $ case eitherDecode msg of
Left reason -> do
gen <- readTVarIO cs.generation
err <- encoded gen Out.Required $ ServerError "invalid_message" (Text.pack reason)
_ <- admit config cs [err]
pure ()
Right clientMsg -> handleClientMessage config master cs clientMsg
readTVarIO cs.outbound >>= hooks.afterReceive
stopped <- readTVarIO cs.closing
case stopped of Just _ -> awaitWriter; Nothing -> pure ()
handleClientMessage :: MetricsServerConfig -> Master -> ConnectionState -> ClientMessage -> IO ()
handleClientMessage config master cs = \case
Ping -> do
gen <- readTVarIO cs.generation
pong <- encoded gen Out.Ephemeral Pong
_ <- admit config cs [pong]
pure ()
message -> do
current <- readTVarIO cs.subscriptions
let next = case message of
SubscribeAll -> AllProcessors Set.empty
Subscribe pids -> case current of
AllProcessors _ -> SelectedProcessors $ Set.fromList pids
SelectedProcessors existing -> SelectedProcessors $ existing <> Set.fromList pids
Unsubscribe pids -> case current of
AllProcessors excluded -> AllProcessors $ excluded <> Set.fromList pids
SelectedProcessors existing -> SelectedProcessors $ existing `Set.difference` Set.fromList pids
if subscriptionSize next > config.wsMaxSubscriptions
then rejectOversizedSubscription config cs
else do
atomically $ do
writeTVar cs.subscriptions next
modifyTVar' cs.generation (+ 1)
modifyTVar' cs.outbound Out.discardUpdates
subscriptionSnapshot config master cs
subscriptionSize :: Subscription -> Int
subscriptionSize = \case
AllProcessors excluded -> Set.size excluded
SelectedProcessors selected -> Set.size selected
subscriptionSnapshot :: MetricsServerConfig -> Master -> ConnectionState -> IO ()
subscriptionSnapshot config master cs = do
subscription <- readTVarIO cs.subscriptions
metrics <- filterMetrics subscription <$> getAllMetricsIO master
gen <- readTVarIO cs.generation
snapshot <- encoded gen Out.Required $ MetricsSnapshot metrics
result <- admit config cs [snapshot]
unless (result == Out.MustClose) $ atomically $ do
writeTVar cs.lastMetrics metrics
writeTVar cs.resyncPending False
rejectOversizedSubscription :: MetricsServerConfig -> ConnectionState -> IO ()
rejectOversizedSubscription config cs = do
let reason = "WebSocket processor subscription limit exceeded (maximum " <> Text.pack (show config.wsMaxSubscriptions) <> ")"
gen <- readTVarIO cs.generation
err <- encoded gen Out.Required $ ServerError "subscription_limit_exceeded" reason
-- Set policy close first in the same producer critical section: capacity failure
-- cannot override 1008. Admission below is atomic with the close signal.
now <- getMonotonicTimeNSec
atomically $ do
q <- readTVar cs.outbound
let (_, next) = Out.enqueue (limits config) [err] q
writeTVar cs.outbound next
writeTVar cs.closing $ Just $ CloseControl 1008 reason (now + fromIntegral config.wsSendTimeoutUs * 1_000)
pushLoop :: WebSocketHooks -> MetricsServerConfig -> Master -> WebSocketState -> ConnectionState -> IO ()
pushLoop hooks config master state cs = loop
where
loop = do
shutdown <- hooks.waitForPush config.wsPushIntervalUs state
withMVar cs.producerLock $ \() -> do
stopped <- readTVarIO cs.closing
case stopped of
Just _ -> pure ()
Nothing -> do
requested <- readTVarIO state.shutdownRequested
if shutdown || requested then orderlyShutdown else pushUpdates config master cs
readTVarIO cs.outbound >>= hooks.afterProduce
stopped <- readTVarIO cs.closing
case stopped of Just _ -> awaitWriter; Nothing -> loop
orderlyShutdown = do
gen <- readTVarIO cs.generation
goodbye <- encoded gen Out.Required Goodbye
atomically $ modifyTVar' cs.outbound Out.discardUpdates
_ <- admit config cs [goodbye]
requestClose config cs 1000 "Server shutting down"
waitForPushOrShutdown :: Int -> WebSocketState -> IO Bool
waitForPushOrShutdown intervalUs state = do
elapsed <- registerDelay intervalUs
atomically $
(readTVar state.shutdownRequested >>= \requested -> check requested >> pure True)
`orElse` (readTVar elapsed >>= \ready -> check ready >> pure False)
pushUpdates :: MetricsServerConfig -> Master -> ConnectionState -> IO ()
pushUpdates config master cs = do
current <- getAllMetricsIO master
lifecycle <- getLifecycleSnapshotIO master
subscription <- readTVarIO cs.subscriptions
previous <- readTVarIO cs.lastMetrics
resync <- readTVarIO cs.resyncPending
gen <- readTVarIO cs.generation
let filtered = filterMetrics subscription current
removed = Map.keysSet previous `Set.difference` Map.keysSet current
terminals =
[ ProcessorTerminal pid status
| pid <- Set.toList removed,
Just status <- [Map.lookup pid lifecycle >>= terminalStatus]
]
terminalFrames <- mapM (encoded gen Out.Required) terminals
if resync
then do
atomically $ modifyTVar' cs.outbound Out.discardUpdates
err <- encoded gen Out.Required $ ServerError "queue_overflow" "Updates dropped; replacing metrics snapshot"
snapshot <- encoded gen Out.Required $ MetricsSnapshot filtered
result <- admit config cs (terminalFrames <> [err, snapshot])
unless (result == Out.MustClose) $ atomically $ do
writeTVar cs.lastMetrics filtered
writeTVar cs.resyncPending False
else do
-- Required terminal events precede updates and survive eviction.
result <- admit config cs terminalFrames
case result of
Out.MustClose -> pure ()
-- Terminal admission can itself evict a delta. Stop ordinary updates
-- immediately, but retire admitted terminals before the recovery tick.
Out.DroppedUpdates -> atomically $ writeTVar cs.lastMetrics $ Map.withoutKeys previous removed
Out.Enqueued -> do
let updates = [ProcessorUpdate pid pm | (pid, pm) <- Map.toList filtered, fmap stableView (Map.lookup pid previous) /= Just (stableView pm)]
frames <- mapM (encoded gen Out.Update) updates
updateResult <- admit config cs frames
unless (updateResult == Out.MustClose) $ atomically $ do
-- Retain the old live comparison ledger after delta loss, but retire
-- admitted terminals so the recovery tick never repeats them.
pending <- readTVar cs.resyncPending
writeTVar cs.lastMetrics $ if pending then Map.withoutKeys previous removed else filtered
writerLoop :: WebSocketHooks -> MetricsServerConfig -> ConnectionState -> WS.Connection -> IO ()
writerLoop hooks config cs conn = loop
where
loop = do
next <- atomically $ do
q <- readTVar cs.outbound
case Out.pop q of
Just (value, rest) -> writeTVar cs.outbound rest >> pure (Left value)
Nothing -> readTVar cs.closing >>= maybe retry (pure . Right)
case next of
Left value -> bounded (hooks.beforeWrite (Out.payload value) >> WS.sendTextData conn (Out.payload value)) >> loop
Right (CloseControl code reason _) -> bounded $ WS.sendCloseCode conn code reason
bounded action = do
now <- getMonotonicTimeNSec
closing <- readTVarIO cs.closing
let remaining = case closing of
Nothing -> config.wsSendTimeoutUs
Just (CloseControl _ _ deadline) -> if now >= deadline then 0 else fromIntegral ((deadline - now) `div` 1_000)
result <- timeout (min config.wsSendTimeoutUs remaining) action
case result of Nothing -> throwIO WS.ConnectionClosed; Just () -> pure ()
filterMetrics :: Subscription -> MetricsMap -> MetricsMap
filterMetrics subscription = Map.filterWithKey $ \pid _ -> case subscription of
AllProcessors excluded -> Set.notMember pid excluded
SelectedProcessors selected -> Set.member pid selected
terminalStatus :: ProcessorLifecycle -> Maybe ProcessorTerminalStatus
terminalStatus = \case
LifecycleStopped -> Just TerminalStopped
LifecycleFailed failure messageId -> Just $ TerminalFailed failure (messageIdText <$> messageId)
LifecycleRunning -> Nothing
LifecycleDraining -> Nothing
messageIdText :: MessageId -> Text
messageIdText (MessageId value) = value
-- Ages grow without a state change. Clients extrapolate from their last frame;
-- snapshots and HTTP scrapes always include a freshly sampled age.
stableView :: Metrics.ProcessorMetrics -> Metrics.ProcessorMetrics
stableView pm = pm {Metrics.oldestInFlight = fmap (\oldest -> oldest {Metrics.ageSeconds = 0}) pm.oldestInFlight}