packages feed

keiro-ops-http-0.22.0.0: src/Keiro/Ops/Http/Feed/Driver.hs

-- | One read loop per canonical view. Only the registry creates production
-- drivers; the injected constructor keeps fallback/diff checks small.
module Keiro.Ops.Http.Feed.Driver
  ( FeedConfig (..),
    defaultFeedConfig,
    validateFeedConfig,
    Driver,
    Subscriber,
    DriverFailure (..),
    ReadResult,
    newDriver,
    startDriver,
    awaitDriver,
    stopDriver,
    subscribe,
    unsubscribe,
    nextFrame,
    subscribeOnQueue,
    unsubscribeSTM,
    driverClosed,
    driverSnapshot,
    enqueueSnapshot,
  )
where

import Control.Concurrent (threadDelay)
import Control.Concurrent.Async (Async, asyncWithUnmask, uninterruptibleCancel)
import Control.Concurrent.STM
import Control.Exception (Exception, SomeAsyncException, SomeException, finally, fromException, mask, onException, throwIO, try)
import Control.Monad (forM_, unless, void)
import Data.Aeson (Value)
import Data.Map.Strict qualified as Map
import Data.Text (Text)
import Data.Time (NominalDiffTime, UTCTime)
import Data.Word (Word64)
import GHC.Clock (getMonotonicTimeNSec)
import GHC.Generics (Generic)
import Keiro.Ops.Http.Budget qualified as Budget
import Keiro.Ops.Http.Config (InspectionLimits (..), defaultInspectionLimits)
import Keiro.Ops.Http.Feed.Queue qualified as Queue
import Keiro.Ops.Http.Protocol qualified as P
import Keiro.Ops.Http.Views
import Keiro.Wake (WakeReason (..))
import System.Timeout (timeout)

data FeedConfig = FeedConfig
  { fallbackInterval :: !NominalDiffTime,
    minRefreshInterval :: !NominalDiffTime,
    maxViews :: !Int,
    maxFrameBytes :: !Int,
    maxQueuedBytes :: !Int,
    readTimeout :: !NominalDiffTime,
    queueCapacity :: !Int,
    idlePingSeconds :: !Int,
    maxConnections :: !Int,
    maxItems :: !Int
  }
  deriving stock (Eq, Show, Generic)

defaultFeedConfig :: FeedConfig
defaultFeedConfig = FeedConfig 1 1 32 1048576 2097152 5 256 30 256 500

validateFeedConfig :: FeedConfig -> IO ()
validateFeedConfig config = unless valid (ioError (userError "feed limits must be positive, representable, and hold control frames (256 bytes)"))
  where
    periods = [config.fallbackInterval, config.minRefreshInterval, config.readTimeout, fromIntegral config.idlePingSeconds]
    valid =
      all (> 0) [config.maxViews, config.queueCapacity, config.maxConnections, config.maxItems]
        && config.maxFrameBytes >= 256
        && config.maxQueuedBytes >= 256
        && all (\period -> period > 0 && ceiling (period * 1000000) <= toInteger (maxBound :: Int)) periods

type ReadResult = (UTCTime, [Item], Maybe Value)

data DriverFailure = DriverFailure !P.SocketErrorCode !Text
  deriving stock (Eq, Show)

instance Exception DriverFailure

data DriverState = DriverState
  { currentSequence :: !Word64,
    rows :: ![Item],
    extra :: !(Maybe Value),
    snapshot :: !(Maybe Queue.Outgoing),
    subscribers :: !(Map.Map Int Queue.OutboundQueue),
    nextId :: !Int,
    closed :: !Bool,
    lastError :: !(Maybe P.SocketErrorCode)
  }

data Driver = Driver
  { name :: !P.FeedName,
    config :: !FeedConfig,
    state :: !(TVar DriverState),
    ready :: !(TMVar (Either DriverFailure ())),
    worker :: !(Async ())
  }

data Subscriber = Subscriber !Int !Queue.OutboundQueue

-- | Production supplies the shared mount budget. The worker, including its
-- initial read, is owned before any caller waits; cancelled waiters cannot
-- strand an initialization task or lose another waiter's shared result.
startDriver :: P.FeedName -> FeedConfig -> Budget.InspectionBudget -> Int -> IO WakeReason -> IO (Either Text ReadResult) -> IO Driver
startDriver name config budget hostDeadline awaitChange readItems = mask $ \_ -> do
  validateFeedConfig config
  state <- newTVarIO (DriverState 0 [] Nothing Nothing Map.empty 0 False Nothing)
  ready <- newEmptyTMVarIO
  worker <- asyncWithUnmask $ \unmask ->
    unmask (run state ready)
      `finally` atomically
        ( do
            modifyTVar' state (\value -> value {closed = True, rows = [], extra = Nothing, snapshot = Nothing, subscribers = Map.empty})
            void (tryPutTMVar ready (Left (DriverFailure P.ReadFailed "feed driver closed")))
        )
  pure (Driver name config state ready worker)
  where
    maximumBytes = min config.maxFrameBytes config.maxQueuedBytes
    deadline = ceiling (min config.readTimeout (fromIntegral hostDeadline) * 1000000)
    cadence = if name == P.Timers then max 5 config.minRefreshInterval else config.minRefreshInterval
    run state ready = do
      firstStart <- getMonotonicTimeNSec
      initial <- bounded (refresh state P.Subscribed)
      atomically (void (tryPutTMVar ready (fmap (const ()) initial)))
      case initial of Left _ -> pure (); Right _ -> loop state firstStart
    loop state previousStart = do
      active <- atomically $ do
        value <- readTVar state
        check (value.closed || not (Map.null value.subscribers))
        pure (not value.closed)
      if not active
        then pure ()
        else do
          reason <- awaitChange
          now <- getMonotonicTimeNSec
          let elapsed = toInteger (now - previousStart) `div` 1000
              remaining = ceiling (cadence * 1000000) - elapsed
          if remaining > 0 then threadDelay (fromInteger remaining) else pure ()
          value <- readTVarIO state
          if value.closed
            then pure ()
            else
              if Map.null value.subscribers
                then loop state previousStart
                else do
                  started <- getMonotonicTimeNSec
                  result <- bounded (refresh state (case reason of WokenByNotify -> P.Notify; WokenByTimeout -> P.Timeout))
                  case result of Left problem -> publishError state problem; Right _ -> pure ()
                  loop state started
    bounded action = do
      admitted <- Budget.withInspectionAdmission budget (trySync (timeout deadline action))
      pure $ case admitted of
        Left _ -> Left (DriverFailure P.InspectionBusy "inspection read capacity is busy")
        Right (Left problem) -> Left problem
        Right (Right Nothing) -> Left (DriverFailure P.ReadFailed "inspection read deadline exceeded")
        Right (Right (Just value)) -> Right value
    refresh state trigger = do
      result <- readItems
      (stamp, items, extra) <- either (const (throwIO (DriverFailure P.ReadFailed "inspection read failed"))) pure result
      previous <- readTVarIO state
      let old = Map.fromList [(item.key, item) | item <- previous.rows]
          current = Map.fromList [(item.key, item) | item <- items]
          changed = [item.body | item <- items, maybe True ((/= stableItemBody name item) . stableItemBody name) (Map.lookup item.key old)]
          removed = [item.keyJson | item <- previous.rows, Map.notMember item.key current]
          different = not (null changed && null removed) || extra /= previous.extra
          sequence = previous.currentSequence + if trigger /= P.Subscribed && different then 1 else 0
      snapshot <- prepare (P.Snapshot name sequence P.Subscribed stamp (map (.body) items) extra)
      update <-
        if trigger /= P.Subscribed && different
          then Just <$> prepare (P.Update name sequence trigger stamp changed removed extra)
          else pure Nothing
      atomically $ do
        value <- readTVar state
        unless value.closed $ do
          writeTVar state value {currentSequence = sequence, rows = items, extra = extra, snapshot = Just snapshot, lastError = Nothing}
          forM_ update $ \frame -> forM_ (Map.elems value.subscribers) (\queue -> void (Queue.enqueueOutgoing queue frame))
    prepare frame =
      Queue.prepareOutgoing maximumBytes frame
        >>= maybe
          (throwIO (DriverFailure P.InspectionResultTooLarge "inspection frame exceeds the host byte limit"))
          pure
    publishError state (DriverFailure code message) = do
      frame <- Queue.prepareOutgoing maximumBytes (P.ErrorFrame code message (Just name))
      atomically $ do
        value <- readTVar state
        unless (value.closed || value.lastError == Just code) $ do
          writeTVar state value {lastError = Just code}
          forM_ frame $ \outgoing -> forM_ (Map.elems value.subscribers) (\queue -> void (Queue.enqueueOutgoing queue outgoing))

trySync :: IO a -> IO (Either DriverFailure a)
trySync action =
  try action >>= \case
    Right value -> pure (Right value)
    Left (err :: SomeException) -> case fromException err :: Maybe SomeAsyncException of
      Just _ -> throwIO err
      Nothing -> pure (Left (maybe (DriverFailure P.ReadFailed "inspection read failed") id (fromException err)))

newDriver :: P.FeedName -> FeedConfig -> IO WakeReason -> IO (Either Text ReadResult) -> IO Driver
newDriver name config awaitChange readItems = mask $ \restore -> do
  budget <- Budget.newInspectionBudget defaultInspectionLimits {maxConcurrentReads = 1}
  driver <- startDriver name config budget (max 1 (ceiling config.readTimeout)) awaitChange readItems
  outcome <- restore (awaitDriver driver) `onException` stopDriver driver
  case outcome of Left problem -> stopDriver driver >> throwIO problem; Right () -> pure driver

awaitDriver :: Driver -> IO (Either DriverFailure ())
awaitDriver driver = atomically (readTMVar driver.ready)

driverClosed :: Driver -> STM Bool
driverClosed driver = (.closed) <$> readTVar driver.state

stopDriver :: Driver -> IO ()
stopDriver driver = mask $ \_ -> do
  atomically $ modifyTVar' driver.state (\value -> value {closed = True, rows = [], extra = Nothing, snapshot = Nothing, subscribers = Map.empty})
  uninterruptibleCancel driver.worker

subscribe :: Driver -> IO Subscriber
subscribe driver = do
  queue <- Queue.newOutboundQueue driver.config.queueCapacity driver.config.maxQueuedBytes driver.config.maxFrameBytes
  atomically (subscribeOnQueue driver queue)

subscribeOnQueue :: Driver -> Queue.OutboundQueue -> STM Subscriber
subscribeOnQueue driver queue = do
  value <- readTVar driver.state
  if value.closed || Map.size value.subscribers >= driver.config.maxConnections
    then throwSTM (DriverFailure P.ReadFailed "driver is closed or subscriber capacity is full")
    else case value.snapshot of
      Nothing -> throwSTM (DriverFailure P.ReadFailed "driver initialization is incomplete")
      Just snapshot -> do
        let identifier = value.nextId
        writeTVar driver.state value {subscribers = Map.insert identifier queue value.subscribers, nextId = identifier + 1}
        void (Queue.enqueueOutgoing queue snapshot)
        pure (Subscriber identifier queue)

unsubscribeSTM :: Driver -> Subscriber -> STM ()
unsubscribeSTM driver (Subscriber identifier queue) = do
  modifyTVar' driver.state (\value -> value {subscribers = Map.delete identifier value.subscribers})
  Queue.purgeFeed queue driver.name

unsubscribe :: Driver -> Subscriber -> IO ()
unsubscribe driver subscriber = atomically (unsubscribeSTM driver subscriber)

nextFrame :: Subscriber -> STM P.ServerFrame
nextFrame (Subscriber _ queue) = Queue.nextFrame queue

-- | The last successful observation, used for resubscription without a read.
driverSnapshot :: Driver -> STM P.ServerFrame
driverSnapshot driver = do
  value <- readTVar driver.state
  case value.snapshot of
    Just outgoing | not value.closed -> pure outgoing.frame
    _ -> throwSTM (DriverFailure P.ReadFailed "driver has no current snapshot")

enqueueSnapshot :: Driver -> Queue.OutboundQueue -> STM ()
enqueueSnapshot driver queue = do
  value <- readTVar driver.state
  case value.snapshot of
    Just outgoing | not value.closed -> void (Queue.enqueueOutgoing queue outgoing)
    _ -> throwSTM (DriverFailure P.ReadFailed "driver has no current snapshot")