packages feed

clickhouse-migrate-0.1.0.0: src/ClickHouseMigrate/Internal/ServerPolicy.hs

{-# LANGUAGE MultilineStrings #-}

-- | Pinned-server admission before application/ledger work. No initialization.
module ClickHouseMigrate.Internal.ServerPolicy
  ( TargetCheckError (..),
    probeTarget,
    withTransport,
    withTransportChecked,
    supportedVersions,
  )
where

import ClickHouseMigrate.Internal.Checksum qualified as Checksum
import ClickHouseMigrate.Internal.Http (Http, probeConnection, submit, withHttpAdmissionChecked)
import ClickHouseMigrate.Internal.TargetTypes
import ClickHouseMigrate.Internal.Transport
import ClickHouseMigrate.Prelude
import ClickHouseMigrate.Target (serverId)
import Data.Aeson (FromJSON, Key, Object, Result (..), fromJSON)
import Data.Aeson.KeyMap qualified as KM
import Data.IORef (atomicModifyIORef', newIORef)
import Data.Set qualified as Set
import Data.Text qualified as Text
import Data.Text.Encoding qualified as Encoding

-- Exact tested-patch policy. Keep coordinated with the checked-in candidate matrix.
supportedVersions :: [Text]
supportedVersions = ["26.8.28.14"]

data TargetCheckError = ProbeFailure !TransportOutcome | TargetRejected !TransportError
  deriving stock (Eq, Show)

probeTarget :: Http -> TargetConfig -> QueryId -> IO (Either TargetCheckError ServerIdentity)
probeTarget http config identity = validateOutcome config <$> submit http identity JsonRows identityQuery

validateOutcome :: TargetConfig -> TransportOutcome -> Either TargetCheckError ServerIdentity
validateOutcome (TargetConfig _ _ expected _ _ _ _) outcome = case outcome of
  AcknowledgedSuccess (Rows [row]) -> validate row
  AcknowledgedSuccess _ -> rejected UnexpectedResponse
  failure -> Left (ProbeFailure failure)
  where
    rejected code = Left (TargetRejected (TransportError code Nothing Nothing))
    validate row
      | Set.fromList (KM.keys row) /= Set.fromList ["server_id", "version", "host", "async_insert", "mutations_sync", "alter_sync"] = rejected UnexpectedResponse
      | otherwise = case (get "server_id" row, get "version" row, get "host" row, get "async_insert" row, get "mutations_sync" row, get "alter_sync" row) of
          (Just uuid, Just version, Just host, Just (async :: Int), Just (mutation :: Int), Just (alter :: Int)) -> case serverId uuid of
            Left _ -> rejected UnexpectedResponse
            Right actual
              | actual /= expected -> rejected ServerIdentityMismatch
              | version `notElem` supportedVersions -> rejected UnsupportedServer
              | async /= 0 || mutation /= 1 || alter /= 1 -> rejected CompletionPolicyMismatch
              | otherwise -> Right (ServerIdentity actual version host)
          _ -> rejected UnexpectedResponse

get :: (FromJSON a) => Key -> Object -> Maybe a
get key row = KM.lookup key row >>= \value -> case fromJSON value of Success result -> Just result; Error _ -> Nothing

identityQuery :: ByteString
identityQuery =
  """
  SELECT toString(serverUUID()) AS server_id, version() AS version, hostName() AS host,
         toUInt8(getSetting('async_insert')) AS async_insert,
         toUInt8(getSetting('mutations_sync')) AS mutations_sync,
         toUInt8(getSetting('alter_sync')) AS alter_sync
  """

-- The only production constructor for a checked transport. Its Http resources
-- close at scope exit; a capability used after exit cannot submit again.
withTransport :: TargetConfig -> QueryId -> (Transport -> IO a) -> IO (ScopeResult (Either TargetCheckError a))
withTransport config probeId = withTransportChecked config probeId (pure (Right ()))

withTransportChecked :: TargetConfig -> QueryId -> IO (Either TransportError ()) -> (Transport -> IO a) -> IO (ScopeResult (Either TargetCheckError a))
withTransportChecked config probeId@(QueryId rootId) check action = do
  counter <- newIORef (0 :: Int)
  let prefix = "chm-admission-" <> Checksum.digestHex (Checksum.digest (Encoding.encodeUtf8 rootId)) <> "-"
      admit connection = do
        n <- atomicModifyIORef' counter (\old -> (old + 1, old))
        outcome <- probeConnection config connection (QueryId (prefix <> Text.pack (show n))) identityQuery
        pure $ case validateOutcome config outcome of
          Right _ -> Right ()
          Left (TargetRejected failure) -> Left failure
          Left (ProbeFailure (NotSubmitted failure)) -> Left failure
          Left (ProbeFailure (SubmissionUncertain failure)) -> Left failure
          Left (ProbeFailure (ObservedServerFailure failure)) -> Left failure
          Left _ -> Left (TransportError UnexpectedResponse Nothing Nothing)
  withHttpAdmissionChecked config admit check $ \http -> do
    checked <- probeTarget http config probeId
    case checked of
      Left failure -> pure (Left failure)
      Right identity -> Right <$> action (Transport (submit http) identity)