pgmq-inspect-0.7.0.0: src/Pgmq/Inspect/WebSocket.hs
-- | Queue metrics feed at @/ws@. Client frames are
-- @{"type":"subscribe","queue":"orders"}@, @unsubscribe@ with the same queue,
-- and @{"type":"ping"}@. A successful subscribe publishes a @snapshot@ before
-- any @update@; snapshots carry @queue@, @metrics@ and @push@, updates carry
-- @queue@, @metrics@ and @source@ (@poll@ or @notify@). No messages are streamed.
--
-- @push@ means notification interest was registered for a validated,
-- non-partitioned queue, not that a listener or trigger is healthy. Polling is
-- authoritative even when every notification is lost. A runtime shares one
-- rate-limited metrics read per distinct queue across connections.
--
-- @pong@ answers a JSON ping; WebSocket control pings run every 30 seconds.
-- @error@ carries @code@, safe @message@ and an always-present nullable @queue@.
-- Bounded outboxes drop the oldest frame and report @overflow@ before the next
-- retained frame; re-read HTTP or re-subscribe after loss. Unsubscribe invalidates
-- queued generations. Bytes already handed to the socket writer cannot be recalled.
-- @goodbye@ precedes a server close, with a bounded best-effort shutdown drain.
--
-- Server JSON examples (QueueMetrics uses its owning domain encoding):
--
-- > {"type":"snapshot","queue":"orders","metrics":{"queue_name":"orders","queue_length":3,"newest_msg_age_sec":1,"oldest_msg_age_sec":9,"total_messages":12,"scrape_time":"2026-09-30T12:00:00Z","queue_visible_length":2,"default_partition_length":null},"push":true}
--
-- > {"type":"update","queue":"orders","metrics":{"queue_name":"orders","queue_length":3,"newest_msg_age_sec":1,"oldest_msg_age_sec":9,"total_messages":12,"scrape_time":"2026-09-30T12:00:00Z","queue_visible_length":2,"default_partition_length":null},"source":"notify"}
--
-- > {"type":"pong"}
--
-- > {"type":"error","code":"overflow","message":"The server dropped frames because this connection was not reading them; re-subscribe or re-read the queue over HTTP.","queue":null}
--
-- > {"type":"goodbye"}
module Pgmq.Inspect.WebSocket
( module Pgmq.Inspect.Frames,
WebSocketState,
newWebSocketState,
requestShutdown,
activeConnections,
awaitDrained,
closeConnections,
websocketApp,
)
where
import Control.Concurrent.Async
import Control.Concurrent.STM
import Control.Exception
import Control.Monad (forever, unless, when)
import Data.Aeson (eitherDecode, encode)
import Data.ByteString.Lazy qualified as LBS
import Data.List (isInfixOf)
import Data.Map.Strict qualified as Map
import Data.Unique (Unique, newUnique)
import Network.WebSockets qualified as WS
import Pgmq.Inspect.Config
import Pgmq.Inspect.Error
import Pgmq.Inspect.Frames
import Pgmq.Inspect.Outbox
import Pgmq.Inspect.Refresh
import System.Timeout (timeout)
-- | Connection count includes handshakes. Tasks are registered before serving.
data WebSocketState = WebSocketState
{ stateLimit :: !Int,
stateCount :: !(TVar Int),
stateShutdown :: !(TVar Bool),
stateTasks :: !(TVar (Map.Map Unique (Async ())))
}
newWebSocketState :: Int -> IO WebSocketState
newWebSocketState limit = WebSocketState limit <$> newTVarIO 0 <*> newTVarIO False <*> newTVarIO Map.empty
requestShutdown :: WebSocketState -> STM ()
requestShutdown = flip writeTVar True . stateShutdown
activeConnections :: WebSocketState -> STM Int
activeConnections = readTVar . stateCount
awaitDrained :: WebSocketState -> Int -> IO ()
awaitDrained state budget = do
_ <- timeout budget (atomically (activeConnections state >>= check . (== 0)))
pure ()
-- | Stop and join remaining transport/handshake tasks, also for host-owned Warp.
closeConnections :: WebSocketState -> IO ()
closeConnections state = readTVarIO (stateTasks state) >>= mapM_ cancel . Map.elems
websocketApp :: InspectConfig -> MetricsCoordinator -> WebSocketState -> WS.ServerApp
websocketApp cfg coordinator state pending = mask $ \restore -> do
slot <- atomically $ do
count <- readTVar (stateCount state)
stopped <- readTVar (stateShutdown state)
if stopped || count >= stateLimit state
then pure False
else writeTVar (stateCount state) (count + 1) >> pure True
if not slot
then
restore $
WS.rejectRequestWith
pending
WS.defaultRejectRequest
{ WS.rejectCode = 429,
WS.rejectMessage = "Too Many Requests",
WS.rejectHeaders = [("Content-Type", "application/json")],
WS.rejectBody = LBS.toStrict (encode inspectionBusy)
}
else flip finally (atomically (modifyTVar' (stateCount state) (subtract 1))) $ do
key <- newUnique
start <- newEmptyTMVarIO
withAsync (atomically (takeTMVar start) >> restore serve) $ \task -> do
registered <- atomically $ do
stopped <- readTVar (stateShutdown state)
if stopped
then pure False
else do
modifyTVar' (stateTasks state) (Map.insert key task)
putTMVar start ()
pure True
when registered $ restore (wait task) `finally` atomically (modifyTVar' (stateTasks state) (Map.delete key))
where
serve =
( do
connection <- WS.acceptRequest pending
subscriber <- newSubscriber (wsQueueCapacity cfg)
transport connection subscriber `finally` releaseSubscriber coordinator subscriber
)
`catch` \(_ :: WS.ConnectionException) -> pure ()
transport connection subscriber = WS.withPingThread connection 30 (pure ()) $ do
let outbox = subscriberOutbox subscriber
writer = do
(overflow, frame) <- atomically (takeOutgoing outbox)
when overflow (WS.sendTextData connection (encode overflowFrame))
WS.sendTextData connection (encode frame)
case frame of
Goodbye -> WS.sendClose connection ("shutdown" :: LBS.ByteString)
_ -> writer
finishWithError code message = do
atomically (enqueue outbox (ErrorFrame code message Nothing))
-- Capacity one must still deliver the error before goodbye.
atomically (queuedFrames outbox >>= check . (== 0))
atomically (enqueue outbox Goodbye)
atomically retry
reader =
( forever $ do
bytes <- WS.receiveData connection :: IO LBS.ByteString
if LBS.length bytes > fromIntegral (wsMaxFrameBytes cfg)
then finishWithError "frame_too_large" "The client frame exceeded the configured size limit."
else case eitherDecode bytes of
Left _ -> atomically (enqueue outbox (ErrorFrame "invalid_frame" "Expected a subscribe, unsubscribe or ping JSON frame." Nothing))
Right frame -> do
stopped <- readTVarIO (stateShutdown state)
unless stopped $ case frame of
Subscribe name -> subscribeQueue coordinator subscriber name
Unsubscribe name -> unsubscribeQueue coordinator subscriber name
Ping -> atomically (enqueue outbox Pong)
)
`catch` \err -> case err of
WS.ParseException message | "limit" `isInfixOf` message -> finishWithError "frame_too_large" "The client frame exceeded the configured size limit."
WS.ParseException _ -> finishWithError "invalid_frame" "The WebSocket frame was invalid."
_ -> throwIO (err :: WS.ConnectionException)
shutdown = do
atomically (readTVar (stateShutdown state) >>= check)
atomically (enqueue outbox Goodbye)
atomically retry
withAsync writer $ \writerTask -> race_ (wait writerTask) (race_ reader shutdown)