clickhouse-migrate-0.1.0.0: src/ClickHouseMigrate/Internal/Http.hs
-- | Dedicated direct HTTP resources, bounded full responses, and no replay.
module ClickHouseMigrate.Internal.Http (Http, withHttp, withHttpAdmission, withHttpAdmissionChecked, withHttpFinalizer, probeConnection, submit, readJsonRowStream) where
import ClickHouseMigrate.Internal.TargetTypes
import ClickHouseMigrate.Internal.Transport hiding (request)
import ClickHouseMigrate.Prelude
import Control.Concurrent (ThreadId, myThreadId, threadDelay, throwTo)
import Control.Concurrent.MVar (MVar, modifyMVar_, newMVar, withMVar)
import Control.Exception (AsyncException (ThreadKilled), Exception, SomeAsyncException, SomeException, bracket, finally, fromException, mask, onException, throwIO, try)
import Data.Aeson (Object, eitherDecodeStrict')
import Data.ByteString qualified as BS
import Data.ByteString.Char8 qualified as Char8
import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef, writeIORef)
import Data.Text qualified as Text
import Data.Text.Encoding qualified as Encoding
import Network.HTTP.Client qualified as HTTP
import Network.HTTP.Client.Internal qualified as Internal
import Network.HTTP.Client.TLS (tlsManagerSettings)
import Network.HTTP.Types.Status (statusCode)
import System.Timeout (timeout)
import Text.Read (readMaybe)
data Http = Http !TargetConfig !HTTP.Manager !(IORef Bool) !(MVar ()) !(IORef Bool) !(IORef [ThreadId])
data AdmissionFailure = AdmissionFailure !TransportError deriving stock (Show)
instance Exception AdmissionFailure
safeError :: TransportErrorCode -> TransportError
safeError c = TransportError c Nothing Nothing
-- http-client's closeManager is a no-op. Track/close every owned socket instead.
-- Async cancellation is rethrown after bounded cleanup; never turn it into success.
withHttp :: TargetConfig -> (Http -> IO a) -> IO (ScopeResult a)
withHttp config = withHttpFinalizer config (pure ())
-- Private qualification hook runs through the real cleanup path after sockets.
withHttpFinalizer :: TargetConfig -> IO () -> (Http -> IO a) -> IO (ScopeResult a)
withHttpFinalizer config finalizer = withHttpOwned config (const (pure (Right ()))) (pure (Right ())) finalizer
withHttpAdmission :: TargetConfig -> (Internal.Connection -> IO (Either TransportError ())) -> (Http -> IO a) -> IO (ScopeResult a)
withHttpAdmission config admit = withHttpAdmissionChecked config admit (pure (Right ()))
withHttpAdmissionChecked :: TargetConfig -> (Internal.Connection -> IO (Either TransportError ())) -> IO (Either TransportError ()) -> (Http -> IO a) -> IO (ScopeResult a)
withHttpAdmissionChecked config admit check = withHttpOwned config admit check (pure ())
withHttpOwned :: TargetConfig -> (Internal.Connection -> IO (Either TransportError ())) -> IO (Either TransportError ()) -> IO () -> (Http -> IO a) -> IO (ScopeResult a)
withHttpOwned config admit check finalizer action = mask $ \restore -> do
submitted <- newIORef False
active <- newIORef True
connections <- newIORef [(0 :: Int, finalizer)]
nextConnection <- newIORef (1 :: Int)
inflight <- newIORef []
gate <- newMVar ()
let track factory = do
open <- factory
pure $ \address host port -> mask $ \restoreOpen -> do
conn <- restoreOpen (open address host port)
closeGate <- newMVar False
connectionId <- atomicModifyIORef' nextConnection (\old -> (old + 1, old))
let close = modifyMVar_ closeGate $ \closed ->
if closed
then pure True
else do
Internal.connectionClose conn `onException` writeIORef active False
atomicModifyIORef' connections (\old -> (filter ((/= connectionId) . fst) old, ()))
pure True
wrapped =
conn
{ Internal.connectionWrite = \bytes -> do
live <- readIORef active
unless live (fail "HTTP scope closed")
check >>= either (throwIO . AdmissionFailure) pure
writeIORef submitted True
Internal.connectionWrite conn bytes,
Internal.connectionClose = close
}
atomicModifyIORef' connections (\old -> ((connectionId, close) : old, ()))
live <- readIORef active
unless live (close >> fail "HTTP scope closed")
admitted <- restoreOpen (admit (conn {Internal.connectionWrite = \bytes -> check >>= either (throwIO . AdmissionFailure) (const (Internal.connectionWrite conn bytes))})) `onException` close
case admitted of
Left failure -> close >> throwIO (AdmissionFailure failure)
Right () -> pure ()
stillLive <- readIORef active
unless stillLive (close >> fail "HTTP scope closed")
pure wrapped
settings =
HTTP.managerSetProxy
HTTP.noProxy
tlsManagerSettings
{ HTTP.managerConnCount = 1,
HTTP.managerIdleConnectionCount = 1,
HTTP.managerRetryableException = const False,
HTTP.managerRawConnection = track (HTTP.managerRawConnection tlsManagerSettings),
HTTP.managerTlsConnection = track (HTTP.managerTlsConnection tlsManagerSettings)
}
manager <- HTTP.newManager settings
result <- try @SomeException (restore (action (Http config manager submitted gate active inflight)))
writeIORef active False
owner <- myThreadId
running <- readIORef inflight
cancelled <- try @SomeException $ timeout 1000000 $ do
mapM_ (\thread -> unless (thread == owner) (throwTo thread ThreadKilled)) running
let wait = do
remaining <- readIORef inflight
unless (null remaining) (threadDelay 1000 >> wait)
wait
closers <- map snd <$> readIORef connections
closed <- mapM (\close -> try @SomeException (timeout 1000000 close)) closers
let observations = cancelled : closed
cleanup =
concatMap
( \case
Right (Just ()) -> []
Right Nothing -> [safeError CleanupTimedOut]
Left _ -> [safeError CleanupFailed]
)
observations
cancelledDuringCleanup = [exception | Left exception <- observations, isJust (fromException exception :: Maybe SomeAsyncException)]
case result of
Left exception | isJust (fromException exception :: Maybe SomeAsyncException) -> throwIO exception
_ | exception : _ <- cancelledDuringCleanup -> throwIO exception
Left _ -> pure (ScopeResult (Left (safeError ClientScopeFailed)) cleanup)
Right value -> pure (ScopeResult (Right value) cleanup)
submit :: Http -> QueryId -> RequestKind -> ByteString -> IO TransportOutcome
submit (Http (TargetConfig _ (DirectEndpoint endpoint) _ _ _ (Credentials user password) (HttpLimits micros maxBytes)) manager submitted gate active inflight) (QueryId identity) kind sql = registered inflight $ withMVar gate $ \() -> do
writeIORef submitted False
live <- readIORef active
if not live || BS.null sql || BS.elem 0 sql
then pure (NotSubmitted (safeError InvalidRequest))
else do
result <- try @SomeException $ timeout micros $ do
request <- makeRequest endpoint user password micros identity kind sql
HTTP.withResponse request manager $ \response -> do
let status = statusCode (HTTP.responseStatus response)
exceptionHeader = lookup "X-ClickHouse-Exception-Code" (HTTP.responseHeaders response)
if kind == JsonRowStream && status == 200 && isNothing exceptionHeader
then readJsonRowStream maxBytes (HTTP.brRead (HTTP.responseBody response))
else do
bytes <- readBounded maxBytes (HTTP.responseBody response)
pure $ case bytes of
Left () -> SubmissionUncertain (safeError ResponseTooLarge)
Right body -> classify kind status exceptionHeader body
didSubmit <- readIORef submitted
case result of
Left exception | isJust (fromException exception :: Maybe SomeAsyncException) -> throwIO exception
Left exception | Just (AdmissionFailure failure) <- fromException exception -> pure (if didSubmit then SubmissionUncertain failure else NotSubmitted failure)
Left _ -> pure $ if didSubmit then SubmissionUncertain (safeError ResponseInterrupted) else NotSubmitted (safeError ConnectionFailed)
Right Nothing -> pure $ if didSubmit then SubmissionUncertain (safeError DeadlineExpired) else NotSubmitted (safeError DeadlineExpired)
Right (Just outcome) -> pure outcome
readBounded :: Int -> HTTP.BodyReader -> IO (Either () ByteString)
readBounded limit reader = loop 0 []
where
loop size chunks = do
chunk <- HTTP.brRead reader
if BS.null chunk
then pure (Right (BS.concat (reverse chunks)))
else
if BS.length chunk > limit - size
then pure (Left ())
else loop (size + BS.length chunk) (chunk : chunks)
-- | One bounded raw row at a time; no decoded prefix escapes before framed EOF.
-- The newline delimiter is excluded from the byte bound; CR in CRLF is included.
-- Fragments avoid quadratic copying when a row arrives in many small chunks.
readJsonRowStream :: Int -> IO ByteString -> IO TransportOutcome
readJsonRowStream limit readChunk = loop 0 [] []
where
failure code = SubmissionUncertain (TransportError code (Just 200) Nothing)
row bytes
| BS.all (`elem` [9, 13, 32]) bytes = Right Nothing
| BS.isPrefixOf "Code: " bytes = Left (classify JsonRows 200 Nothing bytes)
| otherwise = do
_ <- either (const (Left (failure UnexpectedResponse))) Right (Encoding.decodeUtf8' bytes)
case eitherDecodeStrict' bytes :: Either String Object of
Left _ -> Left (failure UnexpectedResponse)
Right value -> Right (Just value)
finish chunks rows = case row (BS.concat (reverse chunks)) of
Left problem -> pure problem
Right value -> pure (AcknowledgedSuccess (Rows (reverse (maybe rows (: rows) value))))
loop size chunks rows = do
chunk <- readChunk
if BS.null chunk then finish chunks rows else consume size chunks rows chunk
consume size chunks rows chunk =
let (piece, rest) = BS.break (== 10) chunk
in if BS.length piece > limit - size
then pure (failure ResponseTooLarge)
else
if BS.null rest
then loop (size + BS.length piece) (piece : chunks) rows
else case row (BS.concat (reverse (piece : chunks))) of
Left problem -> pure problem
Right value ->
let nextRows = maybe rows (: rows) value
remaining = BS.tail rest
in if BS.null remaining then loop 0 [] nextRows else consume 0 [] nextRows remaining
classify :: RequestKind -> Int -> Maybe ByteString -> ByteString -> TransportOutcome
classify kind status exceptionHeader body
| status >= 300 && status < 400 = ObservedServerFailure (TransportError RedirectRejected (Just status) serverCode)
| status /= 200 || isJust exceptionHeader || isJust bodyCode = ObservedServerFailure (TransportError ServerRejected (Just status) serverCode)
| kind == Command = if BS.all (`elem` [9, 10, 13, 32]) body then AcknowledgedSuccess CommandAccepted else unexpected
| otherwise = case traverse (eitherDecodeStrict' :: ByteString -> Either String Object) (filter (not . BS.all (`elem` [9, 13, 32])) (Char8.lines body)) of
Left _ -> unexpected
Right rows -> AcknowledgedSuccess (Rows rows)
where
headerCode = exceptionHeader >>= readMaybe . Char8.unpack
bodyCode = Char8.stripPrefix "Code: " body >>= readMaybe . Char8.unpack . Char8.takeWhile (\c -> c >= '0' && c <= '9')
serverCode = case headerCode of Just code -> Just code; Nothing -> bodyCode
unexpected = SubmissionUncertain (TransportError UnexpectedResponse (Just status) serverCode)
-- Probe on exactly the new owned connection, before returning it to the pool.
-- dummyManaged supplies no cleanup/replay; the factory owns this socket and closes
-- it on failure. The bounded response parser is the same as for ordinary requests.
probeConnection :: TargetConfig -> Internal.Connection -> QueryId -> ByteString -> IO TransportOutcome
probeConnection (TargetConfig _ (DirectEndpoint endpoint) _ _ _ (Credentials user password) (HttpLimits micros maxBytes)) connection (QueryId identity) sql = do
result <- try @SomeException $ timeout micros $ do
request <- makeRequest endpoint user password micros identity JsonRows sql
continuation <- Internal.requestBuilder request connection
bracket
(Internal.getResponse (Just (Internal.MaxHeaderLength 4096)) (Just (Internal.MaxNumberHeaders 64)) (Just micros) request (Internal.dummyManaged connection) continuation)
HTTP.responseClose
( \response -> do
body <- readBounded maxBytes (HTTP.responseBody response)
pure $ case body of
Left () -> SubmissionUncertain (safeError ResponseTooLarge)
Right bytes -> classify JsonRows (statusCode (HTTP.responseStatus response)) (lookup "X-ClickHouse-Exception-Code" (HTTP.responseHeaders response)) bytes
)
case result of
Left exception | isJust (fromException exception :: Maybe SomeAsyncException) -> throwIO exception
Left exception | Just (AdmissionFailure failure) <- fromException exception -> pure (SubmissionUncertain failure)
Left _ -> pure (SubmissionUncertain (safeError ResponseInterrupted))
Right Nothing -> pure (SubmissionUncertain (safeError DeadlineExpired))
Right (Just outcome) -> pure outcome
makeRequest :: Text -> ByteString -> ByteString -> Int -> Text -> RequestKind -> ByteString -> IO HTTP.Request
makeRequest endpoint user password micros identity kind sql = do
base <- HTTP.parseRequest (Text.unpack endpoint)
let request =
HTTP.applyBasicAuth user password $
HTTP.setQueryString
( [ ("query_id", Just (Encoding.encodeUtf8 identity)),
("async_insert", Just "0"),
("mutations_sync", Just "1"),
("alter_sync", Just "1"),
("fsync_metadata", Just "1"),
("wait_end_of_query", Just "1"),
("default_format", Just "JSONEachRow")
]
++ if kind /= Command
then
[ ("max_rows_to_read", Just "0"),
("max_bytes_to_read", Just "0"),
("max_result_rows", Just "0"),
("max_result_bytes", Just "0"),
("read_overflow_mode", Just "throw"),
("result_overflow_mode", Just "throw"),
("timeout_overflow_mode", Just "throw"),
("sort_overflow_mode", Just "throw")
]
else []
)
base
{ HTTP.method = "POST",
HTTP.requestBody = HTTP.RequestBodyBS sql,
HTTP.redirectCount = 0,
HTTP.checkResponse = \_ _ -> pure (),
HTTP.responseTimeout = HTTP.responseTimeoutMicro micros,
HTTP.decompress = const False,
HTTP.requestHeaders = [("Content-Type", "text/plain; charset=utf-8"), ("Accept-Encoding", "identity")]
}
pure request
registered :: IORef [ThreadId] -> IO a -> IO a
registered threads action = mask $ \restore -> do
identity <- myThreadId
atomicModifyIORef' threads (\old -> (identity : old, ()))
restore action `finally` atomicModifyIORef' threads (\old -> (filter (/= identity) old, ()))