packages feed

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

-- | Pure immutable history fold. No timestamp or merge order chooses a winner.
module ClickHouseMigrate.Internal.Projection (project, appendProjection, comparePlan, unresolved, resolvedStep) where

import ClickHouseMigrate.Internal.LedgerCodec (contextFor, definitions, definitionsDigest, validateEvent)
import ClickHouseMigrate.Internal.LedgerTypes
import ClickHouseMigrate.Plan (MigrationPlan)
import ClickHouseMigrate.Prelude
import ClickHouseMigrate.Target (TargetKey)
import Control.Monad (foldM)
import Data.List (findIndex)
import Data.Map.Strict qualified as Map

project :: TargetKey -> [LedgerEvent] -> Either LedgerError Projection
project _ [] = Left LedgerUninitialized
project target rows = do
  ordered <- deduplicate rows
  case ordered of
    first@(LedgerEvent 1 event ledger _ Nothing _ _ _ Nothing (Initialize actual)) : rest
      | target == actual -> do
          validateEvent first
          foldM appendProjection (Projection (LedgerPosition ledger 1 event) target [] Nothing Nothing [first]) rest
      | otherwise -> Left LedgerIdentityMismatch
    _ -> Left MissingInitialization

-- Equality is byte-equivalent because the strict codec admits only canonical v1
-- bytes. Detect identity conflicts before sorting/collapsing, never take a winner.
deduplicate :: [LedgerEvent] -> Either LedgerError [LedgerEvent]
deduplicate rows = do
  (_, bySequence) <- foldM add (Map.empty, Map.empty) rows
  pure (map snd (Map.toAscList bySequence))
  where
    add (ids, sequences) event@(LedgerEvent sequenceNumber identity _ _ _ _ _ _ _ _) = do
      validateEvent event
      case Map.lookup identity ids of Just old | old /= event -> Left EventIdentityConflict; _ -> Right ()
      case Map.lookup sequenceNumber sequences of Just old | old /= event -> Left SequenceConflict; _ -> Right ()
      pure (Map.insert identity event ids, Map.insert sequenceNumber event sequences)

appendProjection :: Projection -> LedgerEvent -> Either LedgerError Projection
appendProjection state event@(LedgerEvent sequenceNumber identity ledger _ attempt _ _ _ context body) = do
  validateEvent event
  let LedgerPosition expected previous _ = position state
  require LedgerIdentityMismatch (ledger == expected)
  require SequenceGap (previous < maxBound && sequenceNumber == previous + 1)
  require EventIdentityConflict (all ((/= identity) . eventId) (events state))
  case body of
    StartMigration _ -> pure ()
    AuthorizeRecovery _ -> pure ()
    _ -> case attempt of
      Nothing -> pure ()
      Just current -> require ImpossibleTransition (all (\old -> runId old == runId event) [old | old <- events state, attemptId old == Just current])
  expectedContext <- case body of
    StartMigration definition -> Just <$> contextFor definition Nothing
    AdoptEntry definition _ -> Just <$> contextFor definition Nothing
    Initialize _ -> pure Nothing
    StartAdoption _ -> pure Nothing
    CompleteAdoption _ -> pure Nothing
    _ -> do
      (definition, _, _, _) <- getActive state
      let index = case body of
            StartStep i _ -> Just i
            SucceedStep i _ -> Just i
            FailStep i _ _ -> Just i
            AcceptCompletion i _ -> Just i
            AuthorizeRecovery (Recovery action _ _ _ _) -> case action of RetryStep i -> Just i; AcceptStep i -> Just i; _ -> Nothing
            _ -> Nothing
      Just <$> contextFor definition index
  require DefinitionMismatch (context == expectedContext)
  next <- transition state attempt body
  pure next {position = LedgerPosition ledger sequenceNumber identity, events = events state ++ [event]}

transition :: Projection -> Maybe Text -> EventBody -> Either LedgerError Projection
transition state attempt body = case body of
  Initialize _ -> Left ImpossibleTransition
  StartMigration definition -> do
    require UnresolvedHistory (isNothing (active state) && isNothing (adoption state))
    require DefinitionMismatch (ordinal definition == length (completed state))
    current <- maybe (Left ImpossibleTransition) Right attempt
    require EventIdentityConflict (current `notElem` allAttempts state)
    require DefinitionMismatch (all (\(RecordedMigration old _ _) -> (component old, migration old) /= (component definition, migration definition)) (completed state))
    pure state {active = Just (ActiveMigration definition current (replicate (length (steps definition)) NeverStarted) Nothing)}
  StartStep index query -> withActive state attempt $ \definition current checkpoints authorization -> do
    require ImpossibleTransition (nextUnresolved checkpoints == Just index && at index checkpoints == Just NeverStarted && query `notElem` allQueries state)
    require ImpossibleTransition (case authorization of Nothing -> True; Just Resume -> True; Just (RetryStep wanted) -> wanted == index; _ -> False)
    pure (ActiveMigration definition current (replace index (Started query) checkpoints) Nothing)
  SucceedStep index query -> withActive state attempt $ \definition current checkpoints authorization -> do
    require ImpossibleTransition (at index checkpoints == Just (Started query) && isNothing authorization)
    pure (ActiveMigration definition current (replace index (Succeeded query) checkpoints) Nothing)
  FailStep index query failure -> withActive state attempt $ \definition current checkpoints authorization -> do
    require ImpossibleTransition (at index checkpoints == Just (Started query) && isNothing authorization)
    pure (ActiveMigration definition current (replace index (Failed query failure) checkpoints) Nothing)
  CompleteMigration -> do
    (definition, current, checkpoints, authorization) <- getActive state
    require ImpossibleTransition (attempt == Just current && all resolvedStep checkpoints && (isNothing authorization || authorization == Just Finalize))
    pure state {completed = completed state ++ [RecordedMigration definition Applied checkpoints], active = Nothing}
  AuthorizeRecovery (Recovery action expected previous next (QuiescenceEvidence target _)) -> do
    (definition, current, checkpoints, _) <- getActive state
    require StaleLedgerState (expected == position state && previous == current && attempt == Just next)
    require LedgerIdentityMismatch (target == targetOf state)
    require EventIdentityConflict (next `notElem` allAttempts state)
    let unfinished = nextUnresolved checkpoints
        uncertain step = case at step checkpoints of Just (Started _) -> True; Just (Failed _ _) -> True; _ -> False
    case action of
      Resume -> require ImpossibleTransition (not (all resolvedStep checkpoints) && all (\step -> resolvedStep step || step == NeverStarted) checkpoints)
      RetryStep step -> require ImpossibleTransition (unfinished == Just step && uncertain step)
      AcceptStep step -> require ImpossibleTransition (unfinished == Just step && uncertain step)
      Finalize -> require ImpossibleTransition (all resolvedStep checkpoints)
    let revised = case action of RetryStep step -> replace step NeverStarted checkpoints; _ -> checkpoints
    pure state {active = Just (ActiveMigration definition next revised (Just action))}
  AcceptCompletion index evidence -> withActive state attempt $ \definition current checkpoints authorization -> do
    require ImpossibleTransition (authorization == Just (AcceptStep index) && nextUnresolved checkpoints == Just index)
    pure (ActiveMigration definition current (replace index (Accepted evidence) checkpoints) Nothing)
  StartAdoption batch@(AdoptionBatch _ _ _ _ count) -> do
    require ImpossibleTransition (null (completed state) && isNothing (active state) && isNothing (adoption state) && count > 0 && isNothing attempt)
    -- Adoption is a fresh-ledger batch, not a second import after a completed batch.
    require ImpossibleTransition (all (\event -> case bodyOf event of StartAdoption _ -> False; _ -> True) (events state))
    pure state {adoption = Just (ActiveAdoption batch [])}
  AdoptEntry definition evidence -> case adoption state of
    Nothing -> Left ImpossibleTransition
    Just (ActiveAdoption batch@(AdoptionBatch _ _ _ _ count) entries) -> do
      require ImpossibleTransition (isNothing attempt && length entries < count && ordinal definition == length entries)
      require DefinitionMismatch (all (\(RecordedMigration old _ _) -> (component old, migration old) /= (component definition, migration definition)) entries)
      pure state {adoption = Just (ActiveAdoption batch (entries ++ [RecordedMigration definition Adopted (replicate (length (steps definition)) (Accepted evidence))]))}
  CompleteAdoption digest -> case adoption state of
    Nothing -> Left ImpossibleTransition
    Just (ActiveAdoption (AdoptionBatch _ _ mapping definitionsHash count) entries) -> do
      require ImpossibleTransition (isNothing attempt && count == length entries && digest == mapping && definitionsDigest [definition | RecordedMigration definition _ _ <- entries] == definitionsHash)
      pure state {completed = entries, adoption = Nothing}
  where
    targetOf (Projection _ target _ _ _ _) = target
    bodyOf (LedgerEvent _ _ _ _ _ _ _ _ _ value) = value

withActive :: Projection -> Maybe Text -> (Definition -> Text -> [StepState] -> Maybe RecoveryAction -> Either LedgerError ActiveMigration) -> Either LedgerError Projection
withActive state attempt action = do
  (definition, current, checkpoints, authorization) <- getActive state
  require ImpossibleTransition (attempt == Just current)
  changed <- action definition current checkpoints authorization
  pure state {active = Just changed}

getActive :: Projection -> Either LedgerError (Definition, Text, [StepState], Maybe RecoveryAction)
getActive state = case (active state, adoption state) of
  (Just (ActiveMigration definition attempt checkpoints authorization), Nothing) -> Right (definition, attempt, checkpoints, authorization)
  _ -> Left ImpossibleTransition

comparePlan :: MigrationPlan -> Projection -> Either LedgerError ()
comparePlan plan state = do
  let supplied = definitions plan
      recorded =
        [definition | RecordedMigration definition _ _ <- completed state]
          ++ [definition | Just (ActiveMigration definition _ _ _) <- [active state]]
          ++ concat [[definition | RecordedMigration definition _ _ <- entries] | Just (ActiveAdoption _ entries) <- [adoption state]]
  require DefinitionMismatch (recorded == take (length recorded) supplied)

unresolved :: Projection -> Bool
unresolved state = isJust (active state) || isJust (adoption state)

resolvedStep :: StepState -> Bool
resolvedStep (Succeeded _) = True
resolvedStep (Accepted _) = True
resolvedStep _ = False

nextUnresolved :: [StepState] -> Maybe Int
nextUnresolved = findIndex (not . resolvedStep)

at :: Int -> [a] -> Maybe a
at index values
  | index < 0 = Nothing
  | otherwise = case drop index values of value : _ -> Just value; _ -> Nothing

replace :: Int -> a -> [a] -> [a]
replace index value values = take index values ++ [value] ++ drop (index + 1) values

allAttempts :: Projection -> [Text]
allAttempts = concatMap (\event -> case body event of StartMigration _ -> maybe [] pure (attemptId event); AuthorizeRecovery (Recovery _ _ _ next _) -> [next]; _ -> []) . events

allQueries :: Projection -> [Text]
allQueries = concatMap (\event -> case body event of StartStep _ query -> [query]; _ -> []) . events

require :: LedgerError -> Bool -> Either LedgerError ()
require _ True = Right ()
require failure False = Left failure