prodapi-web-0.1.0.0: src/Prod/Healthcheck.hs
{-# LANGUAGE DeriveGeneric #-}
module Prod.Healthcheck where
import Control.Concurrent (threadDelay)
import Control.Monad (void, (>=>))
import Data.Aeson (FromJSON (..), ToJSON (..))
import qualified Data.Either as Either
import Data.Foldable (traverse_)
import Data.IORef
import Data.Map (Map)
import qualified Data.Map as Map
import Data.Proxy (Proxy (..))
import qualified Data.Set as Set
import Data.Text (Text)
import qualified Data.Text as Text
import Data.Time.Clock
import GHC.Generics (Generic)
import Prod.Background (BackgroundVal)
import qualified Prod.Background as Background
import qualified Prod.Discovery as Discovery
import Prod.Health (GetReadinessApi, Readiness (..))
import Prod.Tracer (Tracer, contramap)
import qualified Prometheus as Prometheus
import Network.HTTP.Client (Manager, defaultManagerSettings, newManager)
import qualified Servant.Client as ServantClient
type Host = Text
type Port = Int
type Error = Text
data Track
= HealthCheckStarted Host Port
| HealthCheckFinished Host Port Check
| BackgroundTrack Host Port (Background.Track CheckSummary)
deriving (Show)
data Check
= Success UTCTime Readiness
| Failed UTCTime Error
deriving (Show, Generic)
instance ToJSON Check
instance FromJSON Check
resultTime :: Check -> UTCTime
resultTime (Success t _) = t
resultTime (Failed t _) = t
isSuccess :: Check -> Bool
isSuccess (Success _ Ready) = True
isSuccess _ = False
getReadiness :: ServantClient.ClientM Readiness
getReadiness = ServantClient.client (Proxy @GetReadinessApi)
check :: Manager -> Host -> Port -> IO (Either Error Check)
check httpManager host port = do
now <- getCurrentTime
let env = ServantClient.mkClientEnv httpManager (ServantClient.BaseUrl ServantClient.Http (Text.unpack host) port "")
r <- ServantClient.runClientM getReadiness env
case r of
Left err -> pure $ Left $ Text.pack $ show err
Right v -> pure $ Right (Success now v)
data CheckSummary
= CheckSummary
{ lastReady :: Maybe Check
, recentChecks :: [Either Error Check]
}
deriving (Show, Generic)
instance ToJSON CheckSummary
instance FromJSON CheckSummary
-- | Predicate to tell if a Summary contains a long-enough check history to be considered.
healthChecked :: CheckSummary -> Bool
healthChecked c =
length (recentChecks c) >= 3
-- | Predicate to tell if a Summary contains no recent successful healthcheck.
neverHealthy :: CheckSummary -> Bool
neverHealthy c =
not $
any isSuccess $
Either.rights $
recentChecks c
-- | Predicate to tell if the most recent summary exists and is successful.
recentlyHealthy :: CheckSummary -> Bool
recentlyHealthy c =
maybe False isSuccess $
safeHead $
Either.rights $
recentChecks c
emptyCheckSummary :: CheckSummary
emptyCheckSummary = CheckSummary Nothing []
updateSummary :: Either Error Check -> CheckSummary -> CheckSummary
updateSummary v@(Right c) s
| isSuccess c = CheckSummary (Just c) (v : (take 2 (recentChecks s)))
| otherwise = CheckSummary (lastReady s) (v : (take 2 (recentChecks s)))
updateSummary v@(Left _) s =
CheckSummary (lastReady s) (v : (take 2 (recentChecks s)))
type CheckMap = Map (Host, Port) (BackgroundVal CheckSummary)
emptyCheckMap :: CheckMap
emptyCheckMap = Map.empty
initBackgroundCheck ::
SpaceCounters ->
Manager ->
Tracer IO (Background.Track CheckSummary) ->
(Host, Port) ->
IO (BackgroundVal CheckSummary)
initBackgroundCheck cntrs manager tracer (h, p) =
Background.background tracer emptyCheckSummary emptyCheckSummary step
where
step :: CheckSummary -> IO (CheckSummary, CheckSummary)
step st0 = do
ns_healthcheck_count cntrs $ Prometheus.incCounter
res <- check manager h p
threadDelay 5000000
let st1 = updateSummary res st0
pure (st1, st1)
terminateBackgroundCheck :: BackgroundVal CheckSummary -> IO ()
terminateBackgroundCheck = Background.kill
data Space
= Space
{ spacehttpManager :: Manager
, backgroundChecks :: IORef CheckMap
, requestCheck :: (Host, Port) -> IO (BackgroundVal CheckSummary)
, cancelCheck :: (Host, Port) -> IO ()
}
clearSpace :: Space -> IO ()
clearSpace sp = do
v <- atomicModifyIORef' (backgroundChecks sp) (\old -> (Map.empty, old))
traverse_ terminateBackgroundCheck v
data Counters
= Counters
{ healthcheck_added :: !(Prometheus.Vector Text Prometheus.Counter)
, healthcheck_removed :: !(Prometheus.Vector Text Prometheus.Counter)
, healthcheck_count :: !(Prometheus.Vector Text Prometheus.Counter)
}
newCounters :: IO Counters
newCounters =
Counters
<$> counts "healthcheck_added"
<*> counts "healthcheck_removed"
<*> counts "healthchecks"
where
counts k =
Prometheus.register $
Prometheus.vector "ns" $
Prometheus.counter (Prometheus.Info k "")
type WithSpaceCounter = (Prometheus.Counter -> IO ()) -> IO ()
data SpaceCounters
= SpaceCounters
{ ns_healthcheck_added :: WithSpaceCounter
, ns_healthcheck_removed :: WithSpaceCounter
, ns_healthcheck_count :: WithSpaceCounter
}
namespaceCounters :: Namespace -> Counters -> SpaceCounters
namespaceCounters ns cntrs =
SpaceCounters
(withNamespace (healthcheck_added cntrs))
(withNamespace (healthcheck_removed cntrs))
(withNamespace (healthcheck_count cntrs))
where
withNamespace v f = Prometheus.withLabel v ns f
initSpace :: SpaceCounters -> Manager -> Tracer IO Track -> IO Space
initSpace cntrs manager tracer = do
r <- newIORef emptyCheckMap
pure $ Space manager r (add cntrs r manager) (del cntrs r)
where
add :: SpaceCounters -> IORef CheckMap -> Manager -> (Host, Port) -> IO (BackgroundVal CheckSummary)
add cntrs r manager = \hp -> do
c <- Map.lookup hp <$> readIORef r
case c of
Nothing -> doadd cntrs r manager hp
Just v -> pure v
doadd :: SpaceCounters -> IORef CheckMap -> Manager -> (Host, Port) -> IO (BackgroundVal CheckSummary)
doadd cntrs r manager = \hp@(h, p) -> do
ns_healthcheck_added cntrs $ Prometheus.incCounter
c <- initBackgroundCheck cntrs manager (contramap (BackgroundTrack h p) tracer) hp
concurrentlyAdded <- atomicModifyIORef' r (\st0 -> (Map.insertWith (\_ old -> old) hp c st0, Map.lookup hp st0))
case concurrentlyAdded of
Nothing -> pure c
Just leader -> terminateBackgroundCheck c >> pure leader
del :: SpaceCounters -> IORef CheckMap -> (Host, Port) -> IO ()
del cntrs r = \hp -> do
print ("removing", hp)
ns_healthcheck_removed cntrs $ Prometheus.incCounter
c <- atomicModifyIORef' r (\st0 -> (Map.delete hp st0, Map.lookup hp st0))
case c of
Nothing -> pure ()
Just b -> terminateBackgroundCheck b
setChecks :: Space -> [(Host, Port)] -> IO ()
setChecks space hps = do
let wantedSet = Set.fromList hps
currentSet <- Map.keysSet <$> readIORef (backgroundChecks space)
let spurious = currentSet `Set.difference` wantedSet
let missing = wantedSet `Set.difference` currentSet
traverse_ (cancelCheck space) spurious
traverse_ (requestCheck space) missing
cancelDeadChecks :: Space -> IO ()
cancelDeadChecks space = do
summary <- readBackgroundChecks space
traverse_ (cancelCheck space) (deadKeys summary)
{- | Helper to build a Tracer to update hosts to check based on DNS-discovered answers.
Note that the DNSTrack only gives Host, so you need to fmap the port.
-}
setChecksFromDNSDiscovery :: Space -> Discovery.DNSTrack [(Host, Port)] -> IO ()
setChecksFromDNSDiscovery space (Discovery.DNSTrack _ _ (Discovery.BackgroundTrack (Background.RunDone _ newDNSResult))) =
case Discovery.toMaybe newDNSResult of
Just xs -> traverse_ (requestCheck space) xs
Nothing -> pure ()
setChecksFromDNSDiscovery hcrt _ = pure ()
{- | Same as 'setChecksFromDNSDiscovery' but only adding new checks.
You should clear checks of permanently invalid backends.
-}
addChecksFromDNSDiscovery :: Space -> Discovery.DNSTrack [(Host, Port)] -> IO ()
addChecksFromDNSDiscovery space (Discovery.DNSTrack _ _ (Discovery.BackgroundTrack (Background.RunDone _ newDNSResult))) =
case Discovery.toMaybe newDNSResult of
Just xs -> setChecks space xs
Nothing -> pure ()
addChecksFromDNSDiscovery hcrt _ = pure ()
type SummaryMap = Map (Host, Port) CheckSummary
readCheckMap :: CheckMap -> IO SummaryMap
readCheckMap = traverse Background.readBackgroundVal
readBackgroundChecks :: Space -> IO SummaryMap
readBackgroundChecks = readIORef . backgroundChecks >=> readCheckMap
{- | Returns the set of (Host,Port) that are healthy in a given SummaryMap.
Healthiness consists in having the latest healthcheck as healthy.
-}
healthyKeys :: SummaryMap -> [(Host, Port)]
healthyKeys m =
fmap fst $
filter (recentlyHealthy . snd) $
Map.toList m
{- | Returns the set of (Host,Port) that have no recent successful activity
provided there is enough health-checking history.
-}
deadKeys :: SummaryMap -> [(Host, Port)]
deadKeys m =
fmap fst $
filter (\(_, x) -> neverHealthy x && healthChecked x) $
Map.toList m
safeHead :: [a] -> Maybe a
safeHead (x : _) = Just x
safeHead _ = Nothing
type Namespace = Text
type Namespaced a = (Namespace, a)
data Runtime
= Runtime
{ counters :: Counters
, httpManager :: Manager
, tracer :: Tracer IO (Namespaced Track)
, -- todo: split globals env values and dynamic-space storage
spaces :: IORef (Map Namespace Space)
}
initRuntime :: Tracer IO (Namespaced Track) -> IO Runtime
initRuntime tracer = do
r <- newIORef Map.empty
manager <- newManager defaultManagerSettings
cntrs <- newCounters
pure $ Runtime cntrs manager tracer r
readSpaces :: Runtime -> IO (Map Namespace SummaryMap)
readSpaces rt = do
r <- readIORef . spaces $ rt
traverse readBackgroundChecks r
registerSpace :: Runtime -> Namespace -> IO Space
registerSpace rt ns = withSpace rt ns pure
withSpace :: Runtime -> Namespace -> (Space -> IO a) -> IO a
withSpace rt ns run = do
let r = spaces rt
sp <- Map.lookup ns <$> readIORef r
case sp of
Just s -> run s
Nothing -> do
s <- initRuntimeSpace rt ns
concurrentlyAdded <- atomicModifyIORef' r (\st0 -> (Map.insertWith (\_ old -> old) ns s st0, Map.lookup ns st0))
case concurrentlyAdded of
Nothing -> run s
Just leader -> clearSpace s >> run leader
-- | Only create a space (no registration).
initRuntimeSpace :: Runtime -> Namespace -> IO Space
initRuntimeSpace rt ns =
initSpace
(namespaceCounters ns $ counters rt)
(httpManager rt)
(contramap (\x -> (ns, x)) (tracer rt))