packages feed

clickhouse-migrate-test-support-0.1.0.0: src/ClickHouseMigrate/TestSupport/Internal/Fixture.hs

-- | Private RPC/process ownership for native fixtures. Stdout is protocol only.
module ClickHouseMigrate.TestSupport.Internal.Fixture
  ( Fixture,
    Server (..),
    withFixture,
    fixtureRpc,
    fixtureEndpoint,
    fixtureRoot,
    fixtureVersion,
    field,
  )
where

import ClickHouseMigrate.Prelude
import Control.Concurrent.MVar (MVar, newMVar, withMVar)
import Control.Exception (SomeAsyncException, SomeException, fromException, mask, onException, throwIO, try)
import Data.Aeson (FromJSON, Key, Result (..), Value (..), fromJSON, object, (.=))
import Data.Aeson qualified as Aeson
import Data.Aeson.KeyMap qualified as KM
import Data.ByteString.Char8 qualified as BS
import Data.ByteString.Lazy.Char8 qualified as Lazy
import Data.Text qualified as Text
import Paths_clickhouse_migrate_test_support (getDataFileName)
import System.IO (Handle, hClose, hFlush, openTempFile)
import System.Process (CreateProcess (..), ProcessHandle, StdStream (..), createProcess, proc, terminateProcess, waitForProcess)
import System.Timeout (timeout)

data Server = Server !Fixture

data Fixture = Fixture !Handle !Handle !ProcessHandle !(MVar ()) !Text !FilePath !Text

field :: (FromJSON a) => Key -> Value -> IO a
field key (Object value) = case KM.lookup key value of
  Just member -> case fromJSON member of
    Success result -> pure result
    Error _ -> fail "Invalid fixture response field"
  Nothing -> fail "Missing fixture response field"
field _ _ = fail "Invalid fixture response object"

withFixture :: Text -> (Fixture -> IO a) -> IO a
withFixture entry action = mask $ \restore -> do
  script <- getDataFileName "test/scripts/native_fixture.py"
  matrix <- getDataFileName "test/data/clickhouse-matrix.json"
  (logPath, logHandle) <- openTempFile "/tmp" "clickhouse-migrate-fixture.log"
  pipes <-
    createProcess
      ( (proc "python3" [script, "--matrix", matrix, "--entry", Text.unpack entry])
          { std_in = CreatePipe,
            std_out = CreatePipe,
            std_err = UseHandle logHandle
          }
      )
      `onException` hClose logHandle
  case pipes of
    (Just input, Just output, _, process) -> do
      ready <- try @SomeException $ do
        line <- timeout 40000000 (BS.hGetLine output)
        value <- maybe (fail "Fixture startup deadline expired") (either (const (fail "Fixture startup failed")) pure . Aeson.eitherDecodeStrict') line
        endpoint <- field "endpoint" value
        root <- field "root" value
        version <- field "version" value
        gate <- newMVar ()
        pure (Fixture input output process gate endpoint root version)
      case ready of
        Left exception -> do
          terminateProcess process
          _ <- timeout 20000000 (waitForProcess process)
          hClose input
          hClose output
          hClose logHandle
          case fromException exception :: Maybe SomeAsyncException of
            Just _ -> throwIO exception
            Nothing -> fail ("Owned fixture startup failed; evidence: " ++ logPath)
        Right fixture -> do
          result <- try @SomeException (restore (action fixture))
          cleanup <- try @SomeException $ do
            _ <- fixtureRpc fixture (object ["operation" .= ("stop" :: Text), "success" .= either (const False) (const True) result])
            stopped <- timeout 20000000 (waitForProcess process)
            case stopped of
              Nothing -> terminateProcess process >> void (timeout 20000000 (waitForProcess process)) >> fail "Fixture cleanup deadline expired"
              Just _ -> pure ()
          case cleanup of
            Left _ -> terminateProcess process >> void (timeout 20000000 (waitForProcess process))
            Right () -> pure ()
          hClose input
          hClose output
          hClose logHandle
          case result of
            Left exception -> throwIO exception
            Right value -> case cleanup of
              Left exception -> case fromException exception :: Maybe SomeAsyncException of
                Just _ -> throwIO exception
                Nothing -> fail ("Owned fixture cleanup failed; evidence: " ++ logPath)
              Right () -> pure value
    _ -> hClose logHandle >> fail "Owned fixture pipes unavailable"

fixtureRpc :: Fixture -> Value -> IO Value
fixtureRpc (Fixture input output _ gate _ root _) request = withMVar gate $ \() -> do
  Lazy.hPutStrLn input (Aeson.encode request)
  -- ByteString writes to a Handle still use its buffering policy.
  hFlush input
  let deadline = case request of Object fields | KM.lookup "operation" fields == Just (String "restart") -> 40000000; _ -> 15000000
  line <- timeout deadline (BS.hGetLine output)
  reply <- maybe (fail ("Fixture RPC deadline expired; evidence: " ++ root)) (either (const (fail "Invalid fixture protocol")) pure . Aeson.eitherDecodeStrict') line
  case reply of
    Object members -> maybe (fail ("Owned fixture operation failed; evidence: " ++ root)) pure (KM.lookup "result" members)
    _ -> fail "Invalid fixture protocol object"

fixtureEndpoint :: Fixture -> Text
fixtureEndpoint (Fixture _ _ _ _ endpoint _ _) = endpoint

fixtureRoot :: Fixture -> FilePath
fixtureRoot (Fixture _ _ _ _ _ root _) = root

fixtureVersion :: Fixture -> Text
fixtureVersion (Fixture _ _ _ _ _ _ version) = version