packages feed

shomei-server-0.3.0.0: review-load/Main.hs

{-# LANGUAGE ScopedTypeVariables #-}

-- | Isolated MasterPlan 9 workload. No caller-supplied database or HTTP target is accepted.
module Main (main) where

import Control.Concurrent (forkIO, killThread)
import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar)
import Control.Exception (SomeException, bracket, throwIO, try)
import Control.Monad (forM, forM_, replicateM_, unless, void)
import Data.Aeson (Value (..), eitherDecode, encode, object, (.=))
import Data.Aeson.Key qualified as Key
import Data.Aeson.KeyMap qualified as KM
import Data.ByteString qualified as BS
import Data.ByteString.Char8 qualified as BS8
import Data.ByteString.Lazy qualified as LBS
import Data.Foldable (toList)
import Data.IORef (newIORef)
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Text.Encoding qualified as Text
import Data.UUID qualified as UUID
import GHC.Clock (getMonotonicTimeNSec)
import Hasql.Decoders qualified as D
import Hasql.Encoders qualified as E
import Hasql.Session qualified as Session
import Hasql.Statement (preparable)
import Network.HTTP.Client qualified as HTTP
import Network.HTTP.Types (Header, statusCode, urlEncode)
import Network.Wai.Handler.Warp qualified as Warp
import Servant.Health (ProbeVerdict (Healthy))
import Shomei.Account.Password.Hash.Postgres (Argon2Params (..), defaultArgon2Params, newHashingLimiter)
import Shomei.Authorization.Claims.Domain (Audience (..), Issuer (..))
import Shomei.Config (ObservabilityConfig (..), RateLimitConfig (..), ShomeiConfig (..), defaultShomeiConfig)
import Shomei.Id (UserId, genUserId, idText, parseId, userIdToUUID)
import Shomei.Mfa.Totp.Postgres (totpEncryptionKeyFromBytes)
import Shomei.Migrations.TestSupport (withShomeiMigratedDatabase)
import Shomei.Notify (noNotifierSecrets)
import Shomei.Notify.Queue (newNotifierQueue)
import Shomei.Persistence.Database.Postgres (defaultTransactionRetryPolicy)
import Shomei.Persistence.Pool.Postgres (Pool, acquirePool)
import Shomei.Persistence.Pool.Postgres qualified as Pool
import Shomei.Servant.Api (shomeiThrottledRoutes)
import Shomei.Servant.CachedJson (cachedJsonBytes)
import Shomei.Servant.OpenApi (openApiDocument)
import Shomei.Server.App (Env (..))
import Shomei.Server.Boot (application, hostMiddleware)
import Shomei.Server.Config (ProxyProtocolMode (..), ServerSettings (..), defaultSweepSettings)
import Shomei.Server.Keys (bootstrapKeys)
import Shomei.Server.Middleware.RateLimit (newRateLimiterFor)
import Shomei.Server.Middleware.TrustedProxy (emptyTrustedProxies)
import Shomei.Server.Observability.Metrics (newMetrics)
import Shomei.Server.Runtime (hostWarpSettings)
import Shomei.SigningKey.Domain (SigningAlgorithm (ES256))
import Shomei.SigningKey.Protection.Jwt (keyEncryptionKeyFromBase64)
import System.Directory (getTemporaryDirectory, removeFile)
import System.Environment (getArgs, setEnv)
import System.Exit (ExitCode (ExitSuccess), exitFailure)
import System.IO (hClose, hPutStrLn, openBinaryTempFile, stderr)
import System.Mem (performGC)
import System.Posix.Process (getProcessID)
import System.Process (readProcess, readProcessWithExitCode)
import System.Timeout (timeout)
import Text.Read (readMaybe)

main :: IO ()
main = do
  args <- getArgs
  (scenario, output) <- case args of
    ["--scenario", s, "--output", out] | s `elem` ["all", "sessions", "openapi", "argon2"] -> pure (s, out)
    _ -> hPutStrLn stderr "expected --scenario all|sessions|openapi|argon2 --output PATH; external targets are refused" >> exitFailure
  result <- timeout 180000000 (try @SomeException (workload scenario))
  case result of
    Just (Right report) -> LBS.writeFile output (encode report <> "\n")
    _ -> do
      LBS.writeFile output (encode (object ["scenario" .= scenario, "error_count" .= (1 :: Int), "result" .= ("failed_or_timed_out" :: Text)]) <> "\n")
      hPutStrLn stderr "isolated review workload failed; no credential-bearing exception is emitted"
      exitFailure

settings :: ServerSettings
settings =
  ServerSettings
    { serverPort = 0,
      serverBindHost = "127.0.0.1",
      serverMaxConnections = 1024,
      serverSlowClientTimeoutSeconds = 30,
      serverConnStr = "",
      serverTrustedProxies = emptyTrustedProxies,
      serverProxyProtocol = ProxyProtocolOff,
      serverDbPoolSize = 8,
      serverDbPoolAcquisitionTimeoutMs = 10000,
      serverDbStatementTimeoutMs = 30000,
      serverDbTransactionMaxAttempts = 3,
      serverDbTransactionDeadlineMs = 5000,
      serverNotifierQueueSize = 1024,
      serverWarnings = [],
      serverSweep = defaultSweepSettings,
      serverArgon2 = defaultArgon2Params,
      serverHashingMaxConcurrency = 2
    }

workload :: String -> IO Value
workload scenario = withShomeiMigratedDatabase \conn -> bracket (acquirePool 8 10 30000 conn) Pool.release \pool -> do
  started <- getMonotonicTimeNSec
  let base = defaultShomeiConfig (Issuer "review-fixture") (Audience "review-fixture")
      cfg = base {rateLimitConfig = base.rateLimitConfig {rateLimitEnabled = False}, observabilityConfig = base.observabilityConfig {requestLoggingEnabled = False, metricsEnabled = False}}
      kek = either (error . Text.unpack) id (keyEncryptionKeyFromBase64 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
      totpKey = either (error . Text.unpack) id (totpEncryptionKeyFromBytes (BS.replicate 32 0))
  keys <- newIORef =<< bootstrapKeys kek ES256 pool
  manager <- HTTP.newManager (HTTP.managerSetProxy HTTP.noProxy HTTP.defaultManagerSettings {HTTP.managerResponseTimeout = HTTP.responseTimeoutMicro 5000000})
  hashing <- newHashingLimiter 2
  queue <- newNotifierQueue 1024
  limiter <- newRateLimiterFor shomeiThrottledRoutes cfg.rateLimitConfig
  metrics <- newMetrics
  runtime <- hostWarpSettings cfg settings Warp.defaultSettings
  let env =
        Env
          { envPool = pool,
            envDbRetryPolicy = defaultTransactionRetryPolicy,
            envConfig = cfg,
            envKeys = keys,
            envKek = kek,
            envHttpManager = manager,
            envNotifierSecrets = noNotifierSecrets,
            envNotifierQueue = queue,
            envArgon2Params = defaultArgon2Params,
            envHashingLimiter = hashing,
            envTotpKey = totpKey
          }
      app = hostMiddleware cfg settings limiter metrics (application env (pure Healthy) (pure Healthy))
  Warp.testWithApplicationSettings runtime (pure app) \port -> do
    let origin = "http://127.0.0.1:" <> show port
    hPutStrLn stderr "[review-load] session fixture and workload"
    sessions <- if scenario `elem` ["all", "sessions"] then sessionWork pool manager origin else pure Null
    hPutStrLn stderr "[review-load] repeated OpenAPI workload"
    openapi <- if scenario `elem` ["all", "openapi"] then openApiWork manager origin else pure Null
    hPutStrLn stderr "[review-load] bounded Argon2 workload"
    argon2 <- if scenario `elem` ["all", "argon2"] then argonWork origin else pure Null
    ended <- getMonotonicTimeNSec
    pure
      ( object
          [ "scenario" .= scenario,
            "target" .= ("isolated_loopback" :: Text),
            "error_count" .= (0 :: Int),
            "wall_ms" .= (fromIntegral (ended - started) / 1000000 :: Double),
            "configuration"
              .= object
                [ "argon2_memory_kib" .= defaultArgon2Params.memoryKiB,
                  "argon2_iterations" .= defaultArgon2Params.iterations,
                  "argon2_parallelism" .= defaultArgon2Params.parallelism,
                  "hashing_limit" .= (2 :: Int),
                  "rts_requested_capabilities" .= (2 :: Int),
                  "http_workers" .= (4 :: Int),
                  "rate_limit_enabled" .= False,
                  "max_connections" .= (1024 :: Int),
                  "total_deadline_seconds" .= (180 :: Int)
                ],
            "sessions" .= sessions,
            "openapi" .= openapi,
            "argon2" .= argon2
          ]
      )

failCheck :: Bool -> IO ()
failCheck condition = unless condition (ioError (userError "fixed workload acceptance failed"))

checked :: Either e a -> IO a
checked = either (const (ioError (userError "isolated fixture operation failed"))) pure

exec :: Pool -> Text -> IO ()
exec pool sql = checked =<< Pool.use pool (Session.script sql)

scalar :: Pool -> Text -> IO Int
scalar pool sql = fromIntegral <$> (checked =<< Pool.use pool (Session.statement () (preparable sql E.noParams (D.singleRow (D.column (D.nonNullable D.int8))))))

request :: HTTP.Manager -> String -> BS.ByteString -> [Header] -> Maybe Value -> IO (HTTP.Response LBS.ByteString)
request manager url method headers body = do
  req <- HTTP.parseRequest url
  HTTP.httpLbs
    req
      { HTTP.method = method,
        HTTP.requestHeaders = headers,
        HTTP.redirectCount = 0,
        HTTP.requestBody = maybe (HTTP.RequestBodyBS BS.empty) (HTTP.RequestBodyLBS . encode) body
      }
    manager

textAt :: [Text] -> Value -> IO Text
textAt [] (String text) = pure text
textAt (field : rest) (Object fields) = maybe (checked (Left ())) (textAt rest) (KM.lookup (Key.fromText field) fields)
textAt _ _ = checked (Left ())

sessionWork :: Pool -> HTTP.Manager -> String -> IO Value
sessionWork pool manager origin = do
  let password = "review-fixture-correct-horse-42" :: Text
      json = [("Content-Type", "application/json")]
  signup <- request manager (origin <> "/v1/auth/signup") "POST" json (Just (object ["loginId" .= ("review-admin" :: Text), "password" .= password, "displayName" .= ("Review" :: Text)]))
  failCheck (statusCode (HTTP.responseStatus signup) == 201)
  doc <- checked (eitherDecode (HTTP.responseBody signup))
  uidText <- textAt ["user", "userId"] doc
  uid :: UserId <- checked (parseId uidText)
  let uidSql = "'" <> UUID.toText (userIdToUUID uid) <> "'"
  exec pool ("INSERT INTO shomei.shomei_role_grants(user_id,role,granted_at) VALUES(" <> uidSql <> ",'admin',now())")
  login <- request manager (origin <> "/v1/auth/login") "POST" json (Just (object ["loginId" .= ("review-admin" :: Text), "password" .= password]))
  failCheck (statusCode (HTTP.responseStatus login) == 200)
  loginDoc <- checked (eitherDecode (HTTP.responseBody login))
  token <- textAt ["token", "accessToken"] loginDoc
  target <- genUserId
  let targetSql = "'" <> UUID.toText (userIdToUUID target) <> "'"
      headers = [("Authorization", "Bearer " <> Text.encodeUtf8 token)]
      path = origin <> "/v1/admin/users/" <> Text.unpack (idText target) <> "/sessions"
  exec pool ("INSERT INTO shomei.shomei_users(user_id,login_id,status,created_at,updated_at) VALUES(" <> targetSql <> ",'review-target','active',now(),now()); INSERT INTO shomei.shomei_sessions(session_id,user_id,status,kind,created_at,expires_at) SELECT gen_random_uuid()," <> targetSql <> ",'active','interactive',now(),now()+interval '1 day' FROM generate_series(1,10000)")
  started <- getMonotonicTimeNSec
  let walk cursor ids pages = do
        failCheck (pages < 64)
        let suffix = maybe "" (\value -> "&before=" <> BS8.unpack (urlEncode True (Text.encodeUtf8 value))) cursor
        response <- request manager (path <> "?limit=200" <> suffix) "GET" headers Nothing
        failCheck (statusCode (HTTP.responseStatus response) == 200)
        page <- checked (eitherDecode (HTTP.responseBody response))
        values <- case page of
          Object fields | Just (Array rows) <- KM.lookup "sessions" fields -> traverse (textAt ["sessionId"]) (toList rows)
          _ -> checked (Left ())
        failCheck (length values <= 200 && Set.size (Set.fromList values) == length values && Set.null (Set.intersection ids (Set.fromList values)))
        let nextIds = Set.union ids (Set.fromList values)
            next = case page of
              Object fields -> case KM.lookup "nextCursor" fields of
                Just (String c) -> Just c
                _ -> Nothing
              _ -> Nothing
        case next of
          Nothing -> pure (Set.size nextIds, pages + 1)
          Just c -> walk (Just c) nextIds (pages + 1)
  (total, pages) <- walk Nothing Set.empty (0 :: Int)
  failCheck (total == 10000 && pages == 51)
  revoked <- request manager path "DELETE" headers Nothing
  failCheck (statusCode (HTTP.responseStatus revoked) == 204)
  count <- scalar pool ("SELECT count(*) FROM shomei.shomei_sessions WHERE user_id=" <> targetSql <> " AND status='revoked'")
  audits <- scalar pool "SELECT count(*) FROM shomei.shomei_auth_events WHERE event_type='session_revoked'"
  failCheck (count == 10000 && audits == 10000)
  ended <- getMonotonicTimeNSec
  pure
    ( object
        [ "fixture_sessions" .= total,
          "page_size" .= (200 :: Int),
          "pages" .= pages,
          "revoked" .= count,
          "audit_events" .= audits,
          "old_unbounded_page_rows" .= (10000 :: Int),
          "current_max_page_rows" .= (200 :: Int),
          "bulk_unit_of_work_contract_calls" .= (1 :: Int),
          "wall_ms" .= (fromIntegral (ended - started) / 1000000 :: Double)
        ]
    )

parallel :: [IO a] -> IO [a]
parallel actions =
  bracket
    ( forM actions \action -> do
        done <- newEmptyMVar
        worker <- forkIO (try @SomeException action >>= putMVar done)
        pure (worker, done)
    )
    (mapM_ (killThread . fst))
    (traverse (\(_, done) -> takeMVar done >>= either throwIO pure))

rssKiB :: IO Int
rssKiB = do
  performGC
  pid <- getProcessID
  raw <- readProcess "ps" ["-o", "rss=", "-p", show pid] ""
  maybe (checked (Left ())) pure (readMaybe raw)

openApiWork :: HTTP.Manager -> String -> IO Value
openApiWork manager origin = do
  let expected = cachedJsonBytes openApiDocument
      one = do
        response <- request manager (origin <> "/openapi.json") "GET" [] Nothing
        failCheck (statusCode (HTTP.responseStatus response) == 200 && HTTP.responseBody response == expected)
  one
  warm <- rssKiB
  started <- getMonotonicTimeNSec
  samples <- forM [1 .. 4 :: Int] \_ -> do
    void (parallel [replicateM_ count one | count <- [63, 63, 62, 62]])
    rssKiB
  ended <- getMonotonicTimeNSec
  -- Same process includes the bounded load generator. A fixed generous noise allowance
  -- catches retention proportional to 1000 document copies without treating RSS as exact.
  failCheck (maximum samples - warm < 32768)
  pure
    ( object
        [ "requests" .= (1000 :: Int),
          "workers" .= (4 :: Int),
          "error_count" .= (0 :: Int),
          "wire_bytes_per_response" .= LBS.length expected,
          "warm_rss_kib" .= warm,
          "batch_rss_kib" .= samples,
          "memory_growth_allowance_kib" .= (32768 :: Int),
          "observer_shares_process" .= True,
          "wall_ms" .= (fromIntegral (ended - started) / 1000000 :: Double)
        ]
    )

argonWork :: String -> IO Value
argonWork origin = bracket create removeFile \path -> do
  pid <- getProcessID
  forM_ [("LOAD_USERS", "4"), ("PROBE_COUNT", "100"), ("LOAD_SECONDS", "10"), ("SIGNUP_LOOPS", "0"), ("SERVER_PID", show pid), ("LOAD_JSON_OUTPUT", path)] (uncurry setEnv)
  (code, _, _) <- readProcessWithExitCode "sh" ["scripts/argon2-load-test.sh", origin] ""
  failCheck (code == ExitSuccess)
  checked . eitherDecode =<< LBS.readFile path
  where
    create = do
      directory <- getTemporaryDirectory
      (path, handle) <- openBinaryTempFile directory "shomei-review-argon2.json"
      hClose handle
      pure path