packages feed

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

-- | Production liveness and readiness policy for the standalone service.
module Shomei.Health.Server
  ( HealthPolicy (..),
    defaultHealthPolicy,
    buildHealthChecks,
    buildHealthChecksWith,
  )
where

import Control.Concurrent.MVar (modifyMVar, newMVar)
import Data.Word (Word64)
import GHC.Clock (getMonotonicTimeNSec)
import Servant.Health (ProbeCheck)
import Servant.Health.Check
  ( boolCheck,
    newFailureTracker,
    safeCheck,
    sequenceChecks,
    withProbeTimeout,
  )
import Shomei.Error (AuthError)
import Shomei.Server.App (Env, runAppIO)
import Shomei.SigningKey.Domain (StoredSigningKey)
import Shomei.SigningKey.Store (listActiveSigningKeys)

data HealthPolicy = HealthPolicy
  { readinessTimeoutMicros :: !Int,
    readinessCacheMicros :: !Int
  }
  deriving stock (Eq, Show)

defaultHealthPolicy :: HealthPolicy
defaultHealthPolicy =
  HealthPolicy
    { readinessTimeoutMicros = 2_000_000,
      readinessCacheMicros = 1_000_000
    }

-- | Production checks backed by the standalone server's interpreter stack.
buildHealthChecks :: Env -> IO (ProbeCheck, ProbeCheck)
buildHealthChecks env = buildHealthChecksWith defaultHealthPolicy (runAppIO env listActiveSigningKeys)

-- | Construct the two tracked checks once at startup. The injected query is the service-owned
-- IO bridge for @listActiveSigningKeys@: a typed execution failure is reported as @postgres@,
-- while a successful empty result is reported as @signing-key@.
buildHealthChecksWith :: HealthPolicy -> IO (Either AuthError [StoredSigningKey]) -> IO (ProbeCheck, ProbeCheck)
buildHealthChecksWith policy runSigningKeyQuery = do
  trackLiveness <- newFailureTracker
  trackReadiness <- newFailureTracker
  cachedReadiness <-
    cachedFor policy.readinessCacheMicros
      . withProbeTimeout policy.readinessTimeoutMicros "postgres"
      . sequenceChecks
      $ [ safeCheck "postgres" do
            runSigningKeyQuery >>= \case
              Left _ -> boolCheck "postgres" (pure False)
              Right keys -> boolCheck "signing-key" (pure (not (null keys)))
        ]
  let liveness =
        trackLiveness
          . withProbeTimeout 2_000_000 "liveness"
          . safeCheck "liveness"
          $ boolCheck "liveness" (pure True)
      readiness = trackReadiness cachedReadiness
  pure (liveness, readiness)

-- | Cache one readiness verdict for a short monotonic-time window. Holding the MVar while the
-- check runs makes cache refresh single-flight: concurrent probes share one dependency query.
cachedFor :: Int -> ProbeCheck -> IO ProbeCheck
cachedFor micros check
  | micros <= 0 = pure check
  | otherwise = do
      cache <- newMVar Nothing
      let windowNanos = fromIntegral micros * 1_000 :: Word64
      pure $ modifyMVar cache \stored -> do
        now <- getMonotonicTimeNSec
        case stored of
          Just (observedAt, verdict)
            | now >= observedAt,
              now - observedAt < windowNanos ->
                pure (stored, verdict)
          _ -> do
            verdict <- check
            observedAt <- getMonotonicTimeNSec
            pure (Just (observedAt, verdict), verdict)