packages feed

kiroku-store-0.12.0.0: bench/RegressionGate.hs

{-# LANGUAGE MultilineStrings #-}

module Main where

import Control.Exception (finally)
import Control.Lens ((^.))
import Control.Monad (forM, replicateM_, unless)
import Data.Aeson qualified as Aeson
import Data.Generics.Labels ()
import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef)
import Data.Int (Int32, Int64)
import Data.List (intercalate)
import Data.Maybe (isNothing)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Encoding (decodeUtf8Lenient)
import Data.Time.Clock (getCurrentTime)
import Data.Vector qualified as V
import GHC.Clock (getMonotonicTime)
import Hasql.Decoders qualified as D
import Hasql.Pool qualified as Pool
import Hasql.Session qualified as Session
import Hasql.Statement (Statement)
import Hasql.Statement qualified as Statement
import Hasql.Transaction qualified as Tx
import Hasql.Transaction.Sessions qualified as TxSessions
import Kiroku.Store
import Kiroku.Store.SQL qualified as SQL
import Kiroku.Store.Subscription.EventPublisher qualified as Publisher
import Kiroku.Test.Fixtures.CategoryScaling (categoryScalingFixtureSql, categoryScalingHead)
import Kiroku.Test.Postgres (withMigratedTestDatabase, withSharedMigratedPostgres)
import System.Environment (lookupEnv)
import System.Mem (performGC)
import Test.Tasty (localOption, withResource)
import Test.Tasty.Bench

main :: IO ()
main = do
  mode <- lookupEnv "KIROKU_GATE_DIAGNOSE"
  withSharedMigratedPostgres $
    withMigratedTestDatabase $ \controlConnectionString ->
      withMigratedTestDatabase $ \candidateConnectionString ->
        withStore (defaultConnectionSettings controlConnectionString) $ \controlStore ->
          withStore (defaultConnectionSettings candidateConnectionString) $ \candidateStore ->
            withAppendCategoryStores $ \appendControlStore appendCandidateStore appendWarmStore ->
              withCategoryScalingStore $ \scalingStore -> do
                let fourStreams = namedStreams "workload-gate-4" 4
                    eightStreams = namedStreams "workload-gate-8" 8
                seedStreams controlStore (fourStreams <> eightStreams)
                seedStreams candidateStore (fourStreams <> eightStreams)

                runSequentialMultiAppend controlStore fourStreams
                runProductionMultiAppend candidateStore fourStreams
                runSequentialMultiAppend controlStore eightStreams
                runProductionMultiAppend candidateStore eightStreams

                (controlStatement, candidateStatement) <- diagnosticStatements mode appendControlStore appendCandidateStore
                appendWarmCounter <- newIORef 0
                appendControlCounter <- newIORef 0
                appendCandidateCounter <- newIORef 0
                -- Prime each growing table before ANALYZE so prepared plans
                -- see a populated store rather than the empty migration template.
                mapM_
                  ( \_ -> do
                      runAppendWorkload controlStatement appendControlStore appendControlCounter
                      runAppendWorkload candidateStatement appendCandidateStore appendCandidateCounter
                      runAppendWorkload SQL.appendAnyVersion appendWarmStore appendWarmCounter
                  )
                  [1 .. 20 :: Int]

                -- Settle fixture writes and refresh plans before measurement.
                runSetupScript scalingStore "CHECKPOINT"
                mapM_
                  (\store -> runSetupScript store "VACUUM (ANALYZE)")
                  [controlStore, candidateStore, appendControlStore, appendCandidateStore, appendWarmStore, scalingStore]

                case mode of
                  Nothing ->
                    defaultMain
                      [ -- ADR-10: the user accepted migration 0012's write cost.
                        -- Protect the accepted implementation against later SQL
                        -- regressions, keeping the historical comparison diagnostic.
                        localOption (RelStDev 0.01) $
                          localOption WallTime $
                            bgroup
                              "append-accepted-category"
                              [ -- Keep this group first: the disposable cell absorbs
                                -- post-setup effects without growing either append arm.
                                localOption (RelStDev 0.05) $
                                  bench "warm-append-40" $
                                    whnfIO (runAppendWorkload SQL.appendAnyVersion appendWarmStore appendWarmCounter),
                                bench "accepted-control-append-40x32" $
                                  whnfIO (replicateM_ 32 (runAppendWorkload controlStatement appendControlStore appendControlCounter)),
                                bcompareWithin 0.5 1.05 "accepted-control-append-40x32" $
                                  bench "candidate-append-40x32" $
                                    whnfIO (replicateM_ 32 (runAppendWorkload candidateStatement appendCandidateStore appendCandidateCounter))
                              ],
                        withResource
                          (warmPipelineStores controlStore candidateStore fourStreams eightStreams)
                          (const (pure ()))
                          $ \_ ->
                            localOption WallTime $
                              bgroup
                                "append-multi-stream"
                                [ localOption (RelStDev 0.01) $
                                    bench "sequential-control-4" $
                                      whnfIO (runSequentialMultiAppend controlStore fourStreams),
                                  bcompareWithin 0.5 0.90 "sequential-control-4" $
                                    localOption (RelStDev 0.01) $
                                      bench "production-pipeline-4" $
                                        whnfIO (runProductionMultiAppend candidateStore fourStreams),
                                  bench "sequential-control-8" $
                                    whnfIO (runSequentialMultiAppend controlStore eightStreams),
                                  bcompareWithin 0.5 0.90 "sequential-control-8" $
                                    bench "production-pipeline-8" $
                                      whnfIO (runProductionMultiAppend candidateStore eightStreams)
                                ],
                        -- BUG-2 / plan 91 G3: the index-range category reads against
                        -- the LATERAL statements they replaced, on one database seeded
                        -- with the category-scaling fixture. The unpartitioned read
                        -- must not be slower where LATERAL was already cheap, and both
                        -- caught-up polls on 20,000 streams must be 5x faster.
                        localOption WallTime $
                          bgroup
                            "category-read"
                            [ bench "control-exhausted-category" $
                                whnfIO (runPlainReads lateralCategoryRead scalingStore "performance" categoryScalingHead),
                              bcompareWithin 0 1.05 "control-exhausted-category" $
                                bench "candidate-exhausted-category" $
                                  whnfIO (runPlainReads SQL.readCategoryForwardStmt scalingStore "performance" categoryScalingHead),
                              bench "control-page-200-streams-from-0" $
                                whnfIO (runPlainReads lateralCategoryRead scalingStore "performance" 0),
                              bcompareWithin 0 1.05 "control-page-200-streams-from-0" $
                                bench "candidate-page-200-streams-from-0" $
                                  whnfIO (runPlainReads SQL.readCategoryForwardStmt scalingStore "performance" 0),
                              bench "control-page-20000-streams-from-0" $
                                whnfIO (runPlainReads lateralCategoryRead scalingStore "idle" 0),
                              bcompareWithin 0 1.05 "control-page-20000-streams-from-0" $
                                bench "candidate-page-20000-streams-from-0" $
                                  whnfIO (runPlainReads SQL.readCategoryForwardStmt scalingStore "idle" 0),
                              bench "control-plain-caught-up-20000-streams" $
                                whnfIO (runPlainReads lateralCategoryRead scalingStore "idle" categoryScalingHead),
                              bcompareWithin 0 0.20 "control-plain-caught-up-20000-streams" $
                                bench "candidate-plain-caught-up-20000-streams" $
                                  whnfIO (runPlainReads SQL.readCategoryForwardStmt scalingStore "idle" categoryScalingHead),
                              bench "control-group-caught-up-20000-streams" $
                                whnfIO (runGroupReads lateralCategoryGroupRead scalingStore "idle" categoryScalingHead),
                              bcompareWithin 0 0.20 "control-group-caught-up-20000-streams" $
                                bench "candidate-group-caught-up-20000-streams" $
                                  whnfIO (runGroupReads SQL.readCategoryForwardConsumerGroupStmt scalingStore "idle" categoryScalingHead)
                            ]
                      ]
                      `finally` do
                        counts <- mapM readIORef [appendControlCounter, appendCandidateCounter]
                        putStrLn ("append workload counts including setup: " <> show counts)
                  Just "explain-combined-links" -> runAppendExplainDiagnostic True controlStatement candidateStatement appendControlStore appendCandidateStore
                  Just "explain" -> runAppendExplainDiagnostic False controlStatement candidateStatement appendControlStore appendCandidateStore
                  Just label -> runPairedAppendDiagnostic label controlStatement candidateStatement appendControlStore appendCandidateStore appendControlCounter appendCandidateCounter

runSetupScript :: KirokuStore -> Text -> IO ()
runSetupScript store script = do
  result <- Pool.use (store ^. #pool) (Session.script script)
  case result of
    Left err -> error ("workload gate quiescence failed: " <> show err)
    Right () -> pure ()

-- | Refresh a group whose pools/plans have been idle during the append cells.
-- No resource thunk reaches STM, and all setup stays outside measured actions.
warmPipelineStores :: KirokuStore -> KirokuStore -> [(StreamName, Text)] -> [(StreamName, Text)] -> IO ()
warmPipelineStores control candidate fourStreams eightStreams = do
  replicateM_ 20 $ do
    runSequentialMultiAppend control fourStreams
    runProductionMultiAppend candidate fourStreams
    runSequentialMultiAppend control eightStreams
    runProductionMultiAppend candidate eightStreams
  mapM_ (\store -> runSetupScript store "VACUUM (ANALYZE)") [control, candidate]
  runSequentialMultiAppend control fourStreams
  runProductionMultiAppend candidate fourStreams
  runSequentialMultiAppend control eightStreams
  runProductionMultiAppend candidate eightStreams

-- | Diagnostic comparisons retain the default gate's SQL and bounds. Each
-- invocation gets fresh stores. The A/A mode tests the harness with identical
-- SQL and schema; the other modes separate column/statement from index/CHECK cost.
diagnosticStatements ::
  Maybe String ->
  KirokuStore ->
  KirokuStore ->
  IO (Statement SQL.AppendParams (Maybe AppendResult), Statement SQL.AppendParams (Maybe AppendResult))
diagnosticStatements mode control candidate = case mode of
  Nothing -> pure (acceptedCategoryAppendAnyVersion, SQL.appendAnyVersion)
  Just "paired-accepted" -> pure (acceptedCategoryAppendAnyVersion, SQL.appendAnyVersion)
  Just "paired" -> historical
  Just "explain" -> historical
  Just "paired-aa" -> do
    runSetupScript control dropCategoryIndexAndCheck
    runSetupScript candidate dropCategoryIndexAndCheck
    pure (preCategoryAppendAnyVersion, preCategoryAppendAnyVersion)
  Just "paired-column" -> do
    runSetupScript control dropCategoryIndexAndCheck
    runSetupScript candidate dropCategoryIndexAndCheck
    pure (preCategoryAppendAnyVersion, SQL.appendAnyVersion)
  Just "paired-index-check" -> do
    runSetupScript control dropCategoryIndexAndCheck
    pure (SQL.appendAnyVersion, SQL.appendAnyVersion)
  Just "paired-no-publisher" -> do
    -- Diagnostic upper bound only: there are no subscribers in this fixture.
    -- Stopping this worker is not a production optimization or valid subscriber mode.
    Publisher.stopPublisher (candidate ^. #publisher)
    pure (SQL.appendAnyVersion, SQL.appendAnyVersion)
  Just "paired-combined-links" -> pure (SQL.appendAnyVersion, combinedLinksAppend)
  Just "explain-combined-links" -> pure (SQL.appendAnyVersion, combinedLinksAppend)
  Just invalid -> error ("unknown KIROKU_GATE_DIAGNOSE mode: " <> invalid)
  where
    historical = do
      runSetupScript control dropCategoryIndexAndCheck
      pure (preCategoryAppendAnyVersion, SQL.appendAnyVersion)
    dropCategoryIndexAndCheck =
      "DROP INDEX kiroku.ix_stream_events_all_by_category; ALTER TABLE kiroku.stream_events DROP CONSTRAINT ck_stream_events_all_category;"

-- | Benchmark-only hypothesis: one junction INSERT instead of two. This
-- preserves the production bind parameters, row values and result decoder.
combinedLinksAppend :: Statement SQL.AppendParams (Maybe AppendResult)
combinedLinksAppend = Statement.preparable sql SQL.appendParamsEncoder SQL.appendResultDecoder
  where
    production = Statement.toSql SQL.appendAnyVersion
    -- The exact production SQL is extracted below using stable CTE boundaries;
    -- fail rather than silently producing a different candidate after an edit.
    sql = combineJunctionInserts production

combineJunctionInserts :: Text -> Text
combineJunctionInserts production =
  let (beforeSource, sourceAndRest) = T.breakOn "  source_links AS (" production
      (_, allUpdateAndRest) = T.breakOn "  all_update AS (" sourceAndRest
      (allUpdate, allLinksAndResult) = T.breakOn "  all_links AS (" allUpdateAndRest
      (_, result) = T.breakOn "SELECT su.stream_id," allLinksAndResult
      combined =
        """
        event_links AS (
          INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version, category)
          SELECT ne.event_id, su.stream_id, su.initial_version + ne.idx, su.stream_id, su.initial_version + ne.idx, NULL::text
          FROM new_events ne
          CROSS JOIN stream_upsert su
          UNION ALL
          SELECT ne.event_id, 0, au.initial_global_version + ne.idx, su.stream_id, su.initial_version + ne.idx, su.category
          FROM new_events ne
          CROSS JOIN all_update au
          CROSS JOIN stream_upsert su
        )
        """
   in if T.null beforeSource || T.null allUpdate || T.null result
        then error "combined-links prototype could not identify production CTE boundaries"
        else beforeSource <> allUpdate <> combined <> "\n" <> result

-- | Twelve alternating pairs of equal-sized, growing workloads. Every round
-- starts at the same logical stream/event counts in both databases. These are
-- diagnostic samples, never a substitute passing result for the tasty gate.
runPairedAppendDiagnostic ::
  String ->
  Statement SQL.AppendParams (Maybe AppendResult) ->
  Statement SQL.AppendParams (Maybe AppendResult) ->
  KirokuStore ->
  KirokuStore ->
  IORef Int ->
  IORef Int ->
  IO ()
runPairedAppendDiagnostic label controlStatement candidateStatement control candidate controlCounter candidateCounter = do
  putStrLn "PAIR,variant,pair,first,control_ns,candidate_ns,control_before,candidate_before,control_after,candidate_after,pre_gc"
  mapM_ runPair [1 .. 12 :: Int]
  verifyCounts "control" control controlCounter
  verifyCounts "candidate" candidate candidateCounter
  where
    iterations = 32
    timed :: IO () -> IO Integer
    timed action = do
      -- Match tasty-bench: collect before the timer, rather than letting setup
      -- garbage fall unpredictably into one arm of this diagnostic comparison.
      performGC
      start <- getMonotonicTime
      action
      end <- getMonotonicTime
      pure (round ((end - start) * 1e9) :: Integer)
    controlAction = replicateM_ iterations (runAppendWorkload controlStatement control controlCounter)
    candidateAction = replicateM_ iterations (runAppendWorkload candidateStatement candidate candidateCounter)
    runPair pair = do
      beforeControl <- readIORef controlCounter
      beforeCandidate <- readIORef candidateCounter
      unless (beforeControl == beforeCandidate) $ error "diagnostic arms start at unequal workload counts"
      (controlNs, candidateNs) <-
        if odd pair
          then do
            c <- timed controlAction
            n <- timed candidateAction
            pure (c, n)
          else do
            n <- timed candidateAction
            c <- timed controlAction
            pure (c, n)
      afterControl <- readIORef controlCounter
      afterCandidate <- readIORef candidateCounter
      unless (afterControl == beforeControl + iterations && afterCandidate == beforeCandidate + iterations) $
        error "diagnostic arms did not complete equal work"
      putStrLn $
        intercalate
          ","
          [ "PAIR",
            label,
            show pair,
            if odd pair then "control" else "candidate",
            show controlNs,
            show candidateNs,
            show beforeControl,
            show beforeCandidate,
            show afterControl,
            show afterCandidate,
            "1"
          ]
    verifyCounts arm store counter = do
      completed <- readIORef counter
      let expectedEvents = fromIntegral (40 * completed) :: Int64
          expectedStreams = fromIntegral (20 * completed + 2) :: Int64
      result <- Pool.use (store ^. #pool) (Session.statement () diagnosticCounts)
      case result of
        Right counts@(streams, events, links, hotVersion, globalVersion) -> do
          unless (counts == (expectedStreams, expectedEvents, 2 * expectedEvents, expectedEvents `div` 2, expectedEvents)) $
            error ("diagnostic database counts differ for " <> arm <> ": " <> show counts)
          putStrLn $ intercalate "," ["COUNTS", label, arm, show streams, show events, show links, show hotVersion, show globalVersion]
        Left err -> error ("diagnostic count verification failed: " <> show err)

-- | Use the exact statements under comparison, rather than a copied EXPLAIN
-- query. These four plans show structure/buffers/WAL, not a latency estimate.
runAppendExplainDiagnostic ::
  Bool ->
  Statement SQL.AppendParams (Maybe AppendResult) ->
  Statement SQL.AppendParams (Maybe AppendResult) ->
  KirokuStore ->
  KirokuStore ->
  IO ()
runAppendExplainDiagnostic timings controlStatement candidateStatement control candidate = do
  mapM_
    ( \(arm, statement, store) -> do
        mapM_ (explainOne arm statement store) [("fresh", "append-gate-explain-fresh"), ("hot", "append-gate-hot")]
        rows <- Pool.use (store ^. #pool) (Session.statement () physicalRows)
        case rows of
          Right value -> putStrLn ("PHYSICAL," <> arm <> "," <> T.unpack value)
          Left err -> error ("diagnostic physical row query failed: " <> show err)
    )
    [("control", controlStatement, control), ("candidate", candidateStatement, candidate)]
  where
    jsonText = D.singleRow (D.column (D.nonNullable (D.jsonBytes (Right . decodeUtf8Lenient))))
    physicalRows =
      Statement.unpreparable
        "SELECT json_agg(row_to_json(x)) FROM (SELECT stream_id = 0 AS all_row, category IS NULL AS category_is_null, pg_column_size(se) AS row_bytes, count(*) AS samples FROM stream_events se GROUP BY 1,2,3 ORDER BY 1,2,3) x"
        mempty
        jsonText
    explainOne arm statement store (kind, name) = do
      enriched <- enrichEvents (store ^. #storeSettings) [makeEvent "AppendCategoryGate"]
      prepared <- prepareEvents enriched
      now <- getCurrentTime
      let explain =
            Statement.unpreparable
              ("EXPLAIN (ANALYZE, BUFFERS, WAL, TIMING " <> (if timings then "ON" else "OFF") <> ", FORMAT JSON) " <> Statement.toSql statement)
              SQL.appendParamsEncoder
              jsonText
      result <- Pool.use (store ^. #pool) (Session.statement (buildAppendParams name now prepared) explain)
      case result of
        Right value -> putStrLn ("PLAN," <> arm <> "," <> kind <> "," <> T.unpack value)
        Left err -> error ("diagnostic EXPLAIN failed: " <> show err)

diagnosticCounts :: Statement () (Int64, Int64, Int64, Int64, Int64)
diagnosticCounts =
  Statement.unpreparable
    "SELECT (SELECT count(*) FROM streams), (SELECT count(*) FROM events), (SELECT count(*) FROM stream_events), (SELECT stream_version FROM streams WHERE stream_name = 'append-gate-hot'), (SELECT stream_version FROM streams WHERE stream_id = 0)"
    mempty
    (D.singleRow ((,,,,) <$> int8 <*> int8 <*> int8 <*> int8 <*> int8))
  where
    int8 = D.column (D.nonNullable D.int8)

-- | A migrated store seeded with 'categoryScalingFixtureSql'.
withCategoryScalingStore :: (KirokuStore -> IO a) -> IO a
withCategoryScalingStore action =
  withMigratedTestDatabase $ \connectionString ->
    withStore (defaultConnectionSettings connectionString) $ \store -> do
      seeded <- Pool.use (store ^. #pool) (Session.script categoryScalingFixtureSql)
      case seeded of
        Left err -> error ("category-read gate setup failed: " <> show err)
        Right () -> action store

-- | Ten executions of an unpartitioned category read (limit 100). Ten, not a
-- hundred: a LATERAL control poll on 20,000 streams costs about 15 ms, and a
-- hundred per iteration exceeds tasty-bench's timeout.
runPlainReads ::
  Statement (Int64, Text, Int32) (V.Vector RecordedEvent) ->
  KirokuStore ->
  Text ->
  Int64 ->
  IO ()
runPlainReads statement store category cursor =
  mapM_
    (\_ -> Pool.use (store ^. #pool) (Session.statement (cursor, category, 100) statement) >>= forceReads)
    [1 .. 10 :: Int]

-- | Ten executions of a consumer-group category read, member 1 of 2 (limit 100).
runGroupReads ::
  Statement (Int64, Text, Int32, Int32, Int32) (V.Vector RecordedEvent) ->
  KirokuStore ->
  Text ->
  Int64 ->
  IO ()
runGroupReads statement store category cursor =
  mapM_
    (\_ -> Pool.use (store ^. #pool) (Session.statement (cursor, category, 1, 2, 100) statement) >>= forceReads)
    [1 .. 10 :: Int]

forceReads :: Either Pool.UsageError (V.Vector RecordedEvent) -> IO ()
forceReads (Right events) = V.length events `seq` pure ()
forceReads (Left err) = error ("category-read gate read failed: " <> show err)

-- | 'SQL.readCategoryForwardStmt' as it was before plan 91 (git 12d50d5).
lateralCategoryRead :: Statement (Int64, Text, Int32) (V.Vector RecordedEvent)
lateralCategoryRead =
  Statement.preparable
    lateralCategoryReadSQL
    SQL.readCategoryEncoder
    (D.rowVector SQL.recordedEventRow)

-- | 'SQL.readCategoryForwardConsumerGroupStmt' as it was before plan 91 (git 12d50d5).
lateralCategoryGroupRead :: Statement (Int64, Text, Int32, Int32, Int32) (V.Vector RecordedEvent)
lateralCategoryGroupRead =
  Statement.preparable
    lateralCategoryGroupReadSQL
    SQL.readCategoryConsumerGroupEncoder
    (D.rowVector SQL.recordedEventRow)

lateralCategoryReadSQL :: Text
lateralCategoryReadSQL =
  """
  SELECT e.event_id, e.event_type,
         se.stream_version, se.stream_version AS global_position,
         se.original_stream_id, se.original_stream_version,
         e.data, e.metadata, e.causation_id, e.correlation_id,
         e.created_at
  FROM streams s
  JOIN LATERAL (
    SELECT se.*
    FROM stream_events se
    WHERE se.stream_id = 0
      AND se.original_stream_id = s.stream_id
      AND se.stream_version > $1
    ORDER BY se.stream_version ASC
    LIMIT $3
  ) se ON true
  JOIN events e ON e.event_id = se.event_id
  WHERE s.category = $2
  ORDER BY se.stream_version ASC
  LIMIT $3
  """

lateralCategoryGroupReadSQL :: Text
lateralCategoryGroupReadSQL =
  """
  SELECT e.event_id, e.event_type,
         se.stream_version, se.stream_version AS global_position,
         se.original_stream_id, se.original_stream_version,
         e.data, e.metadata, e.causation_id, e.correlation_id,
         e.created_at
  FROM streams s
  JOIN LATERAL (
    SELECT se.*
    FROM stream_events se
    WHERE se.stream_id = 0
      AND se.original_stream_id = s.stream_id
      AND se.stream_version > $1
    ORDER BY se.stream_version ASC
    LIMIT $5
  ) se ON true
  JOIN events e ON e.event_id = se.event_id
  WHERE s.category = $2
    AND (((hashtextextended(s.stream_id::text, 0) % $4) + $4) % $4) = $3
  ORDER BY se.stream_version ASC
  LIMIT $5
  """

-- | Three fully migrated stores: the frozen accepted control, current
-- production candidate, and disposable warm-up. Historical diagnostic modes
-- explicitly remove the category index/CHECK from the appropriate arm.
withAppendCategoryStores :: (KirokuStore -> KirokuStore -> KirokuStore -> IO a) -> IO a
withAppendCategoryStores action =
  withMigratedTestDatabase $ \controlConnectionString ->
    withMigratedTestDatabase $ \candidateConnectionString ->
      withMigratedTestDatabase $ \warmConnectionString ->
        withStore (defaultConnectionSettings warmConnectionString) $ \warmStore ->
          withStore (defaultConnectionSettings controlConnectionString) $ \controlStore ->
            withStore (defaultConnectionSettings candidateConnectionString) $ \candidateStore ->
              action controlStore candidateStore warmStore

-- | One gate iteration: 20 single-event appends to fresh streams, then 20 to
-- one hot stream. Each store gets its own counter, so control and candidate
-- create the same stream names and do the same work.
runAppendWorkload ::
  Statement SQL.AppendParams (Maybe AppendResult) ->
  KirokuStore ->
  IORef Int ->
  IO ()
runAppendWorkload statement store counter = do
  iteration <- atomicModifyIORef' counter (\n -> (n + 1, n))
  now <- getCurrentTime
  let freshNames =
        [ "append-gate-" <> T.pack (show iteration) <> "-" <> T.pack (show index)
        | index <- [1 .. 20 :: Int]
        ]
      names = freshNames <> replicate 20 "append-gate-hot"
  mapM_
    ( \name -> do
        enriched <- enrichEvents (store ^. #storeSettings) [makeEvent "AppendCategoryGate"]
        prepared <- prepareEvents enriched
        result <-
          Pool.use (store ^. #pool) $
            Session.statement (buildAppendParams name now prepared) statement
        case result of
          Right (Just appendResult) -> forceAppendResults [appendResult]
          Right Nothing -> error "append-category gate append returned no row"
          Left err -> error ("append-category gate append failed: " <> show err)
    )
    names

-- | Frozen post-0012 append SQL from the user-accepted production source at
-- git 22ac4de. Keep this literal independent of SQL.appendAnyVersion so later
-- production changes are compared against the accepted implementation.
acceptedCategoryAppendAnyVersion :: Statement SQL.AppendParams (Maybe AppendResult)
acceptedCategoryAppendAnyVersion =
  Statement.preparable acceptedCategoryAppendAnyVersionSQL SQL.appendParamsEncoder SQL.appendResultDecoder

acceptedCategoryAppendAnyVersionSQL :: Text
acceptedCategoryAppendAnyVersionSQL =
  """
  WITH
    new_events AS (
      SELECT *
      FROM unnest($1::uuid[], $2::text[], $3::uuid[], $4::uuid[], $5::jsonb[], $6::jsonb[], $7::timestamptz[])
      WITH ORDINALITY AS t(event_id, event_type, causation_id, correlation_id, data, metadata, created_at, idx)
    ),
    stream_upsert AS (
      INSERT INTO streams (stream_name, stream_version)
      VALUES ($8, (SELECT count(*) FROM new_events))
      ON CONFLICT (stream_name)
      DO UPDATE SET stream_version = streams.stream_version + (SELECT count(*) FROM new_events)
        WHERE streams.deleted_at IS NULL
      RETURNING stream_id, category, stream_version - (SELECT count(*) FROM new_events) AS initial_version
    ),
    inserted_events AS (
      INSERT INTO events (event_id, event_type, causation_id, correlation_id, data, metadata, created_at)
      SELECT event_id, event_type, causation_id, correlation_id, data, metadata, created_at
      FROM new_events
      WHERE EXISTS (SELECT 1 FROM stream_upsert)
      ORDER BY idx
    ),
    source_links AS (
      INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version)
      SELECT ne.event_id, su.stream_id, su.initial_version + ne.idx, su.stream_id, su.initial_version + ne.idx
      FROM new_events ne
      CROSS JOIN stream_upsert su
    ),
    all_update AS (
      UPDATE streams
      SET stream_version = stream_version + (SELECT count(*) FROM new_events)
      WHERE stream_id = 0
        AND EXISTS (SELECT 1 FROM stream_upsert)
      RETURNING stream_version - (SELECT count(*) FROM new_events) AS initial_global_version
    ),
    all_links AS (
      INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version, category)
      SELECT ne.event_id, 0, au.initial_global_version + ne.idx, su.stream_id, su.initial_version + ne.idx, su.category
      FROM new_events ne
      CROSS JOIN all_update au
      CROSS JOIN stream_upsert su
    )
  SELECT su.stream_id,
         su.initial_version + (SELECT count(*) FROM new_events),
         au.initial_global_version + (SELECT count(*) FROM new_events)
  FROM stream_upsert su
  CROSS JOIN all_update au
  """

-- | 'SQL.appendAnyVersion' as it was before migration 0012 (git 12d50d5).
preCategoryAppendAnyVersion :: Statement SQL.AppendParams (Maybe AppendResult)
preCategoryAppendAnyVersion =
  Statement.preparable
    preCategoryAppendAnyVersionSQL
    SQL.appendParamsEncoder
    SQL.appendResultDecoder

preCategoryAppendAnyVersionSQL :: Text
preCategoryAppendAnyVersionSQL =
  """
  WITH
    new_events AS (
      SELECT *
      FROM unnest($1::uuid[], $2::text[], $3::uuid[], $4::uuid[], $5::jsonb[], $6::jsonb[], $7::timestamptz[])
      WITH ORDINALITY AS t(event_id, event_type, causation_id, correlation_id, data, metadata, created_at, idx)
    ),
    stream_upsert AS (
      INSERT INTO streams (stream_name, stream_version)
      VALUES ($8, (SELECT count(*) FROM new_events))
      ON CONFLICT (stream_name)
      DO UPDATE SET stream_version = streams.stream_version + (SELECT count(*) FROM new_events)
        WHERE streams.deleted_at IS NULL
      RETURNING stream_id, stream_version - (SELECT count(*) FROM new_events) AS initial_version
    ),
    inserted_events AS (
      INSERT INTO events (event_id, event_type, causation_id, correlation_id, data, metadata, created_at)
      SELECT event_id, event_type, causation_id, correlation_id, data, metadata, created_at
      FROM new_events
      WHERE EXISTS (SELECT 1 FROM stream_upsert)
      ORDER BY idx
    ),
    source_links AS (
      INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version)
      SELECT ne.event_id, su.stream_id, su.initial_version + ne.idx, su.stream_id, su.initial_version + ne.idx
      FROM new_events ne
      CROSS JOIN stream_upsert su
    ),
    all_update AS (
      UPDATE streams
      SET stream_version = stream_version + (SELECT count(*) FROM new_events)
      WHERE stream_id = 0
        AND EXISTS (SELECT 1 FROM stream_upsert)
      RETURNING stream_version - (SELECT count(*) FROM new_events) AS initial_global_version
    ),
    all_links AS (
      INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version)
      SELECT ne.event_id, 0, au.initial_global_version + ne.idx, su.stream_id, su.initial_version + ne.idx
      FROM new_events ne
      CROSS JOIN all_update au
      CROSS JOIN stream_upsert su
    )
  SELECT su.stream_id,
         su.initial_version + (SELECT count(*) FROM new_events),
         au.initial_global_version + (SELECT count(*) FROM new_events)
  FROM stream_upsert su
  CROSS JOIN all_update au
  """

namedStreams :: Text -> Int -> [(StreamName, Text)]
namedStreams prefix count =
  [ ( StreamName (prefix <> "-" <> T.pack (show index)),
      "WorkloadGate" <> T.pack (show index)
    )
  | index <- [1 .. count]
  ]

seedStreams :: KirokuStore -> [(StreamName, Text)] -> IO ()
seedStreams store streams =
  mapM_
    ( \(streamName, eventType) -> do
        result <- runStoreIO store $ appendToStream streamName NoStream [makeEvent (eventType <> "Seed")]
        forceStoreResults "seed append" (fmap pure result)
    )
    streams

runProductionMultiAppend :: KirokuStore -> [(StreamName, Text)] -> IO ()
runProductionMultiAppend store streams = do
  result <-
    runStoreIO store $
      appendMultiStream
        [ (streamName, AnyVersion, [makeEvent eventType])
        | (streamName, eventType) <- streams
        ]
  forceStoreResults "production appendMultiStream" result

runSequentialMultiAppend :: KirokuStore -> [(StreamName, Text)] -> IO ()
runSequentialMultiAppend store streams = do
  now <- getCurrentTime
  preparedOps <-
    forM streams $ \(streamName@(StreamName name), eventType) -> do
      enriched <- enrichEvents (store ^. #storeSettings) [makeEvent eventType]
      prepared <- prepareEvents enriched
      pure (streamName, name, buildAppendParams name now prepared)
  let names = V.fromList [name | (_, name, _) <- preparedOps]
      transaction = do
        Tx.statement names SQL.lockStreamsForMultiStmt
        results <-
          forM preparedOps $ \(_, _, params) ->
            appendDispatchTx AnyVersion params
        if any isNothing results
          then Tx.condemn >> pure results
          else pure results
  result <-
    Pool.use (store ^. #pool) $
      TxSessions.transaction TxSessions.ReadCommitted TxSessions.Write transaction
  case result of
    Left err -> error ("sequential appendMultiStream control failed: " <> show err)
    Right maybeResults -> do
      unless (all isJustAppend maybeResults) $
        error "sequential appendMultiStream control returned an empty append result"
      forceAppendResults [appendResult | Just appendResult <- maybeResults]

makeEvent :: Text -> EventData
makeEvent eventType =
  EventData
    { eventId = Nothing,
      eventType = EventType eventType,
      payload = Aeson.object [("workloadGate", Aeson.Bool True)],
      metadata = Nothing,
      causationId = Nothing,
      correlationId = Nothing
    }

isJustAppend :: Maybe AppendResult -> Bool
isJustAppend (Just _) = True
isJustAppend Nothing = False

forceStoreResults :: String -> Either StoreError [AppendResult] -> IO ()
forceStoreResults _ (Right results) = forceAppendResults results
forceStoreResults label (Left err) = error (label <> " failed: " <> show err)

forceAppendResults :: [AppendResult] -> IO ()
forceAppendResults =
  mapM_ $ \result ->
    (result ^. #streamId) `seq`
      (result ^. #streamVersion) `seq`
        (result ^. #globalPosition) `seq`
          pure ()