packages feed

keiro-0.6.0.0: src/Keiro/ReadModel/Rebuild.hs

-- | The supported offline read-model rebuild lifecycle.
--
-- Use this checklist rather than composing the low-level status transitions:
--
-- 1. Call 'Keiro.ReadModel.Schema.registerReadModel' once at projection startup.
--    Explicit registration makes misspelled or never-populated models fail with
--    'ReadModelUnregistered' instead of appearing healthy.
-- 2. Call 'startRebuild' with every feeding async projection name and the replay
--    position. It atomically fences live writers, takes queries offline, truncates
--    the data table, clears the named dedup keys, and resets the subscription
--    checkpoint, preventing live/replay interleaving and all-deduplicated rebuilds.
-- 3. Replay through 'Keiro.Projection.applyAsyncProjectionUnfenced'. This is the
--    only apply path allowed to bypass the live-writer fence, while retaining
--    deduplication inside the designated rebuild.
-- 4. After replay catches up and application-specific verification succeeds, call
--    'finishRebuild' with the same projection names and replay position. Its
--    promotion guard refuses to serve a non-empty-log rebuild that applied no
--    events.
-- 5. If replay or verification fails, call 'abandonRebuild'. Queries remain
--    unavailable instead of exposing partial data; repair or restore the table
--    before beginning another rebuild.
--
-- Normal workers continue to call 'Keiro.Projection.applyAsyncProjection'. Its
-- registry lock fences them automatically while the model is rebuilding, but they
-- must not checkpoint an 'Keiro.Projection.AsyncFenced' event. Keiro does not yet
-- provide a shadow-table or online cutover mechanism; applications that need
-- zero-downtime rebuilds must build that orchestration above this lifecycle API.
module Keiro.ReadModel.Rebuild
  ( RebuildError (..),
    startRebuild,
    finishRebuild,
    rebuild,
    promote,
    abandonRebuild,
  )
where

import Contravariant.Extras (contrazip2)
import Data.Text.Encoding qualified as TE
import Effectful (Eff, (:>))
import Hasql.Decoders qualified as D
import Hasql.Encoders qualified as E
import Hasql.Statement (Statement, preparable)
import Keiro.Prelude
import Keiro.ReadModel
import Kiroku.Store.Effect (Store)
import Kiroku.Store.Transaction (runTransaction)
import Kiroku.Store.Types (GlobalPosition (..))
import "hasql-transaction" Hasql.Transaction qualified as Tx

-- | Why a rebuild could not be promoted.
data RebuildError
  = -- | The model name and current store head when a rebuild started before
    --       existing events but applied none of its feeding projections.
    RebuildProducedNoApplies !Text !GlobalPosition
  deriving stock (Generic, Eq, Show)

-- | Atomically take a model offline, truncate its data table, clear the dedup
-- keys for its feeding async projections, and reset every member of its
-- subscription to the supplied replay position.
--
-- The registry transition runs first and holds the row lock that
-- 'Keiro.Projection.applyAsyncProjection' uses as its writer fence. PostgreSQL
-- keeps the table truncate transactional. Kiroku's public checkpoint save is
-- monotonic, so this helper deliberately resets @subscriptions.last_seen@
-- directly inside the same fenced transaction.
startRebuild ::
  (Store :> es) =>
  ReadModel q r ->
  [Text] ->
  GlobalPosition ->
  Eff es ReadModelMetadata
startRebuild readModel projectionNames replayFrom =
  runTransaction $ do
    metadata <- transitionReadModelTxFor readModel Rebuilding
    Tx.sql (TE.encodeUtf8 ("TRUNCATE TABLE " <> qualifiedTableName readModel))
    unless (null projectionNames) $
      Tx.statement projectionNames deleteProjectionDedupStmt
    Tx.statement
      (readModel ^. #subscriptionName, globalPositionToInt replayFrom)
      resetSubscriptionCheckpointStmt
    pure metadata

-- | Promote a completed rebuild in the same transaction as its safety check.
--
-- When async projection names are supplied, a store head beyond @replayFrom@
-- requires at least one new dedup row. 'startRebuild' deleted those rows, so their
-- count is the model-independent number of projection applications during this
-- rebuild. An empty projection-name list denotes an inline-only model and skips
-- the guard because no async dedup rows can exist for it.
finishRebuild ::
  (Store :> es) =>
  ReadModel q r ->
  [Text] ->
  GlobalPosition ->
  Eff es (Either RebuildError ReadModelMetadata)
finishRebuild readModel projectionNames replayFrom =
  runTransaction $ do
    headPosition <- Tx.statement () storeHeadPositionStmt
    applyCount <-
      if null projectionNames
        then pure 0
        else Tx.statement projectionNames countProjectionDedupStmt
    if null projectionNames || applyCount > 0 || headPosition <= replayFrom
      then Right <$> transitionReadModelTxFor readModel Live
      else pure (Left (RebuildProducedNoApplies (readModel ^. #name) headPosition))

-- | Low-level status transition only. It does not truncate data, reset dedup
-- keys or checkpoints, or establish the complete rebuild workflow. Use
-- 'startRebuild' for supported rebuilds.
rebuild :: (Store :> es) => ReadModel q r -> Eff es ReadModelMetadata
rebuild readModel =
  markRebuilding
    (readModel ^. #name)
    (readModel ^. #version)
    (readModel ^. #shapeHash)

-- | Low-level status transition only. It bypasses the empty-rebuild guard. Use
-- 'finishRebuild' to promote a supported rebuild.
promote :: (Store :> es) => ReadModel q r -> Eff es ReadModelMetadata
promote readModel =
  markLive
    (readModel ^. #name)
    (readModel ^. #version)
    (readModel ^. #shapeHash)

-- | Mark a model 'Abandoned', backing out of an in-progress rebuild.
abandonRebuild :: (Store :> es) => ReadModel q r -> Eff es ReadModelMetadata
abandonRebuild readModel =
  markAbandoned
    (readModel ^. #name)
    (readModel ^. #version)
    (readModel ^. #shapeHash)

transitionReadModelTxFor :: ReadModel q r -> ReadModelStatus -> Tx.Transaction ReadModelMetadata
transitionReadModelTxFor readModel status =
  transitionReadModelTx
    (readModel ^. #name)
    (readModel ^. #version)
    (readModel ^. #shapeHash)
    status

deleteProjectionDedupStmt :: Statement [Text] ()
deleteProjectionDedupStmt =
  preparable
    """
    DELETE FROM keiro.keiro_projection_dedup
    WHERE projection_name = ANY($1)
    """
    (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.text))))
    D.noResult

countProjectionDedupStmt :: Statement [Text] Int64
countProjectionDedupStmt =
  preparable
    """
    SELECT count(*)
    FROM keiro.keiro_projection_dedup
    WHERE projection_name = ANY($1)
    """
    (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.text))))
    (D.singleRow (D.column (D.nonNullable D.int8)))

resetSubscriptionCheckpointStmt :: Statement (Text, Int64) ()
resetSubscriptionCheckpointStmt =
  preparable
    """
    UPDATE subscriptions
    SET last_seen = $2, updated_at = now()
    WHERE subscription_name = $1
    """
    ( contrazip2
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.int8))
    )
    D.noResult

storeHeadPositionStmt :: Statement () GlobalPosition
storeHeadPositionStmt =
  preparable
    """
    SELECT COALESCE(max(stream_version), 0)
    FROM stream_events
    WHERE stream_id = 0
    """
    E.noParams
    (D.singleRow (GlobalPosition <$> D.column (D.nonNullable D.int8)))

globalPositionToInt :: GlobalPosition -> Int64
globalPositionToInt (GlobalPosition position) = position