packages feed

shomei-server-0.3.0.0: src/Shomei/Server/Supervisor.hs

-- | The house idiom for supervised background threads.
--
-- A /background thread/ here means a periodic maintenance task forked at server boot: the
-- expired-data sweeper ("Shomei.Persistence.Maintenance.Postgres"), the signing-key reload
-- ("Shomei.Server.Keys"), and whatever later plans add. Every one of them wants the same
-- three things, and none of them wants a supervisor hierarchy:
--
-- 1. Run a cycle, then sleep, forever.
-- 2. If a cycle throws, log loudly and try again later — never take the server down. A failed
--    sweep is strictly less bad than downtime, and the realistic failure mode is "the database
--    was briefly unreachable", which fixes itself.
-- 3. Back off while the failure persists, so a database outage does not turn into a tight
--    retry loop that fills the disk with log lines.
--
-- 'supervisedLoop' is that, and nothing more. It is not respawned by a monitor thread, and it
-- dies with the process: fork it with plain @forkIO@ from @Shomei.Server.Boot.main@ and let
-- process exit collect it. No shutdown plumbing is needed because every cycle is idempotent —
-- a cycle interrupted by exit simply did not happen, and the next boot picks it up.
module Shomei.Server.Supervisor
  ( supervisedLoop,
    supervisedLoopMicros,
    logJsonLine,
  )
where

import Control.Concurrent (threadDelay)
import Control.Exception (SomeAsyncException, SomeException, fromException, throwIO, try)
import Data.Aeson (Value, encode, object, (.=))
import Data.Aeson.Key (Key)
import Data.ByteString.Lazy qualified as BL
import Data.Text (Text)
import Data.Text qualified as Text
import System.IO (stderr)

-- | Seconds to wait after the first crash.
initialBackoffSeconds :: Int
initialBackoffSeconds = 5

-- | The ceiling the backoff doubles up to. Five minutes is long enough that a night-long
-- outage costs a few hundred log lines rather than a few million, and short enough that
-- recovery is noticed promptly.
maxBackoffSeconds :: Int
maxBackoffSeconds = 300

-- | Run @cycleAction@ forever, sleeping @intervalSeconds@ between clean cycles.
--
-- The first cycle runs immediately, so a freshly booted server does its maintenance at once
-- rather than after a full interval. A crash — any synchronous 'SomeException' — is caught,
-- logged as one structured JSON line on stderr, and retried after an exponential backoff
-- (5 s, doubling to a 300 s ceiling). The backoff resets after the next clean cycle. The loop
-- never rethrows a synchronous exception.
--
-- Asynchronous exceptions are re-thrown rather than swallowed, so that killing the thread (or
-- the process shutting down) actually stops it instead of being mistaken for a failed cycle
-- and retried. @taskName@ appears in every log line this loop emits.
supervisedLoop ::
  -- | Task name, e.g. @"sweeper"@.
  Text ->
  -- | Seconds to sleep between clean cycles.
  Int ->
  -- | One cycle.
  IO () ->
  IO ()
supervisedLoop taskName intervalSeconds =
  -- A non-positive interval would spin the loop at full speed, so clamp to one second: a
  -- misconfigured task that runs too often is recoverable, one that pins a core is not.
  supervisedLoopMicros
    taskName
    (max 1 intervalSeconds * microsPerSecond)
    (initialBackoffSeconds * microsPerSecond)
    (maxBackoffSeconds * microsPerSecond)

-- | 'supervisedLoop' with every duration in microseconds and the backoff bounds given
-- explicitly. Exposed so tests can exercise the crash/backoff/recovery path in milliseconds
-- instead of minutes; production code wants 'supervisedLoop'.
supervisedLoopMicros ::
  -- | Task name.
  Text ->
  -- | Microseconds between clean cycles.
  Int ->
  -- | Microseconds to wait after the first crash.
  Int ->
  -- | Ceiling the backoff doubles up to.
  Int ->
  -- | One cycle.
  IO () ->
  IO ()
supervisedLoopMicros taskName intervalMicros initialBackoff maxBackoff cycleAction =
  loop initialBackoff
  where
    loop backoff = do
      outcome <- try cycleAction
      case outcome of
        Right () -> do
          threadDelay intervalMicros
          loop initialBackoff
        Left err
          -- A ThreadKilled or an async cancellation means someone wants this thread gone.
          | isAsyncException err -> throwIO err
          | otherwise -> do
              logCrash taskName err backoff
              threadDelay backoff
              loop (min maxBackoff (backoff * 2))

microsPerSecond :: Int
microsPerSecond = 1_000_000

-- | Is this an asynchronous exception (@ThreadKilled@, @AsyncCancelled@, a timeout)? Both
-- @AsyncException@ and @async@'s @AsyncCancelled@ route through 'SomeAsyncException', so this
-- one check covers them.
isAsyncException :: SomeException -> Bool
isAsyncException err = case fromException err of
  Just (_ :: SomeAsyncException) -> True
  Nothing -> False

-- | @backoffMicros@ is the wait about to be taken, so successive crashes log 5, 10, 20, …
logCrash :: Text -> SomeException -> Int -> IO ()
logCrash taskName err backoffMicros =
  logJsonLine
    [ "level" .= ("error" :: Text),
      "msg" .= ("background task crashed" :: Text),
      "task" .= taskName,
      "error" .= Text.pack (show err),
      "backoff_s" .= (fromIntegral backoffMicros / 1_000_000 :: Double)
    ]

-- | Emit one structured JSON log line on stderr. Background tasks log here rather than on
-- stdout, where 'Shomei.Server.Observability.Logging' emits one line per HTTP request.
logJsonLine :: [(Key, Value)] -> IO ()
logJsonLine fields = BL.hPut stderr (encode (object fields) <> "\n")