packages feed

keiro-0.6.0.0: src/Keiro/ReadModel.hs

-- | Querying the read side, with explicit consistency.
--
-- A 'ReadModel' is a named, versioned SQL projection table plus the query that
-- reads it. Querying it does more than run SQL: 'runQuery' first verifies the
-- table's registered schema is current and 'Live' (rejecting a stale or
-- mid-rebuild model), then honours the requested 'ConsistencyMode' before
-- running the query in a transaction.
--
-- The consistency modes trade freshness against latency:
--
-- * 'Strong' — capture the store head position at query start and block until
--   the model's subscription cursor reaches it.
-- * 'Eventual' — query immediately. Read-your-writes is the projection worker's
--   responsibility under 'Eventual'.
-- * 'PositionWait' — block until the model's subscription has caught up to a
--   target 'GlobalPosition' (typically the position returned by the command
--   the caller just ran), giving read-your-writes against an asynchronous
--   projection. 'waitFor' implements the polling loop and times out with
--   'ReadModelWaitTimeout'.
--
-- Schema lifecycle (registration, status transitions) lives in
-- "Keiro.ReadModel.Schema", which is re-exported here.
--
-- Register each model once at projection startup with 'registerReadModel' before
-- serving queries. Queries fail with 'ReadModelUnregistered' when startup wiring
-- has not registered the model; they never create registry rows themselves.
module Keiro.ReadModel
  ( -- * Definition
    ReadModel (..),
    qualifiedTableName,

    -- * Consistency
    ConsistencyMode (..),
    StrongScope (..),
    PositionWaitOptions (..),
    defaultStrongWaitOptions,

    -- * Querying
    runQuery,
    runQueryWith,
    waitFor,
    readSubscriptionPosition,
    storeHeadPosition,
    categoryHeadPosition,

    -- * Errors
    ReadModelError (..),

    -- * Schema lifecycle
    module Keiro.ReadModel.Schema,
  )
where

import Control.Concurrent (threadDelay)
import Data.Time.Clock (diffUTCTime)
import Data.Vector qualified as Vector
import Effectful (Eff, IOE, (:>))
import Hasql.Decoders qualified as D
import Hasql.Encoders qualified as E
import Hasql.Statement (Statement, preparable)
import Keiro.Connection (qualifyTable)
import Keiro.Prelude
import Keiro.ReadModel.Schema
import Keiro.Telemetry (KeiroMetrics, recordProjectionWaitTimeouts)
import Kiroku.Store.Effect (Store)
import Kiroku.Store.Read (readAllBackward)
import Kiroku.Store.Transaction (runTransaction)
import Kiroku.Store.Types (GlobalPosition (..))
import "hasql-transaction" Hasql.Transaction qualified as Tx
import Prelude qualified

-- | A queryable read-side projection over a query input @q@ and result @r@.
--
-- * 'name' — logical identity, also the key in the @keiro_read_models@
--   registry.
-- * 'tableName' — the underlying projection table.
-- * 'schema' — the PostgreSQL schema the read-model /data/ table lives in. The
--   application qualifies its 'query' SQL against this schema (typically via
--   'Keiro.Connection.qualifyTable' or 'qualifiedTableName'); Keiro does not
--   rewrite 'query'. This is the application's data schema and is entirely
--   separate from Keiro's own @keiro@ schema, where the @keiro_read_models@
--   registry lives. It is deliberately not persisted (see 'ensureReadModel').
-- * 'subscriptionName' — the cursor that tracks how far the projection worker
--   has consumed the event log; consulted by 'PositionWait'.
-- * 'version' \/ 'shapeHash' — schema identity; a query fails with
--   'ReadModelStaleSchema' if the registered values diverge, forcing a rebuild.
-- * 'defaultConsistency' — the 'ConsistencyMode' used by 'runQuery'.
-- * 'strongScope' — the event-log head a 'Strong' query waits for.
-- * 'query' — the SQL read, as a 'Hasql.Transaction.Transaction'.
data ReadModel q r = ReadModel
  { name :: !Text,
    tableName :: !Text,
    schema :: !Text,
    subscriptionName :: !Text,
    version :: !Int,
    shapeHash :: !Text,
    defaultConsistency :: !ConsistencyMode,
    strongScope :: !StrongScope,
    query :: !(q -> Tx.Transaction r)
  }
  deriving stock (Generic)

-- | The read model's fully-qualified, double-quoted table reference
-- @"schema"."table"@, for interpolation into the application's projection SQL.
-- Equal to @'Keiro.Connection.qualifyTable' ('schema' rm) ('tableName' rm)@.
qualifiedTableName :: ReadModel q r -> Text
qualifiedTableName readModel =
  qualifyTable (readModel ^. #schema) (readModel ^. #tableName)

-- | How fresh a read must be before the query runs.
--
-- 'Strong' waits for the model's subscription to reach the store head captured
-- at query start according to the model's 'strongScope'. It is intended for
-- asynchronous read models with a worker advancing that subscription cursor;
-- inline-only models should use 'Eventual' because they have no subscription
-- worker to advance while waiting.
-- 'PositionWait' blocks until the projection has caught up to a caller-supplied
-- target log position (or times out). 'Eventual' queries immediately.
data ConsistencyMode
  = Strong
  | Eventual
  | PositionWait !PositionWaitOptions
  deriving stock (Generic, Eq, Show)

-- | Which log head a 'Strong' read must reach.
--
-- 'EntireLog' preserves the original behavior and is live only when the model's
-- subscription observes every event. A category subscription should use
-- 'CategoryHead' with its Kiroku category, so unrelated categories cannot hold
-- the read behind forever. A model fed by multiple categories should use
-- 'PositionWait' for an explicit write position or 'EntireLog' with a matching
-- all-stream subscription.
--
-- Kiroku currently does not advance category checkpoints on empty fetches. If it
-- does so in a future release, category-scoped targets may become unnecessary,
-- but the explicit model contract remains valid.
data StrongScope
  = EntireLog
  | CategoryHead !Text
  deriving stock (Generic, Eq, Show)

-- | Parameters for a 'PositionWait' query.
--
-- * 'target' — the 'GlobalPosition' the projection must reach; 'Nothing'
--   skips waiting entirely.
-- * 'timeoutMicros' — give up after this long with 'ReadModelWaitTimeout'.
-- * 'pollMicros' — delay between subscription-position checks.
data PositionWaitOptions = PositionWaitOptions
  { target :: !(Maybe GlobalPosition),
    timeoutMicros :: !Int,
    pollMicros :: !Int
  }
  deriving stock (Generic, Eq, Show)

-- | Default wait settings used by 'Strong': wait up to five seconds, polling
-- every 10ms, for the store head captured at query start.
defaultStrongWaitOptions :: PositionWaitOptions
defaultStrongWaitOptions =
  PositionWaitOptions
    { target = Nothing,
      timeoutMicros = 5000000,
      pollMicros = 10000
    }

-- | Why a read-model query could not run.
data ReadModelError
  = -- | No registry row exists for the model. Register it once at projection
    --       startup with 'registerReadModel' before serving queries.
    ReadModelUnregistered !Text
  | -- | The registered schema (version or shape hash) differs from the
    --       model's current definition: name, expected vs. found version, then
    --       expected vs. found shape hash. The model must be rebuilt.
    ReadModelStaleSchema !Text !Int !Int !Text !Text
  | -- | A 'PositionWait' query timed out: model name, target position, and
    --       the last observed subscription position.
    ReadModelWaitTimeout !Text !GlobalPosition !GlobalPosition
  | -- | The model is registered but not 'Live' (e.g. rebuilding or
    --       abandoned): name and current status.
    ReadModelNotLive !Text !ReadModelStatus
  deriving stock (Generic, Eq, Show)

-- | Query a read model using its 'defaultConsistency'. Validates schema and
-- liveness first, waits if the mode requires it, then runs the query.
runQuery ::
  (IOE :> es, Store :> es) =>
  Maybe KeiroMetrics ->
  ReadModel q r ->
  q ->
  Eff es (Either ReadModelError r)
runQuery metrics readModel =
  runQueryWith metrics (readModel ^. #defaultConsistency) readModel

-- | Query a read model with an explicit 'ConsistencyMode', overriding its
-- default. Validates the model's schema and liveness, honours the wait mode,
-- then runs the query in a transaction.
runQueryWith ::
  (IOE :> es, Store :> es) =>
  Maybe KeiroMetrics ->
  ConsistencyMode ->
  ReadModel q r ->
  q ->
  Eff es (Either ReadModelError r)
runQueryWith metrics consistency readModel input = do
  schemaCheck <- ensureReadModel readModel
  case schemaCheck of
    Left err -> pure (Left err)
    Right () -> do
      waitResult <- waitIfNeeded metrics consistency readModel
      case waitResult of
        Left err -> pure (Left err)
        Right () -> Right <$> runTransaction ((readModel ^. #query) input)

-- | Block until the model's subscription has advanced to @targetPosition@,
-- polling at 'pollMicros' intervals. Returns @Right ()@ once caught up, or
-- 'ReadModelWaitTimeout' if 'timeoutMicros' elapses first.
waitFor ::
  (IOE :> es, Store :> es) =>
  Maybe KeiroMetrics ->
  PositionWaitOptions ->
  ReadModel q r ->
  GlobalPosition ->
  Eff es (Either ReadModelError ())
waitFor metrics options readModel targetPosition = do
  started <- liftIO getCurrentTime
  poll started (GlobalPosition 0)
  where
    poll started observed = do
      current <- readSubscriptionPosition (readModel ^. #subscriptionName)
      let observed' = fromMaybe observed current
      if observed' >= targetPosition
        then pure (Right ())
        else do
          now <- liftIO getCurrentTime
          let elapsedMicros =
                Prelude.floor
                  (diffUTCTime now started Prelude.* 1000000)
          if elapsedMicros >= options ^. #timeoutMicros
            then do
              -- A genuine give-up: bump keiro.projection.wait.timeouts (no-op
              -- under a 'Nothing' handle) before surfacing the timeout.
              recordProjectionWaitTimeouts metrics 1
              pure
                (Left (ReadModelWaitTimeout (readModel ^. #name) targetPosition observed'))
            else do
              liftIO (threadDelay (options ^. #pollMicros))
              poll started observed'

-- Note: the read model's 'schema' field is deliberately NOT persisted here. The
-- registry keys on name/version/shapeHash/status (the model's schema identity);
-- where the application's data table physically lives is a deployment/wiring
-- concern, not part of that identity. See EP-4's Decision Log.
ensureReadModel ::
  (Store :> es) =>
  ReadModel q r ->
  Eff es (Either ReadModelError ())
ensureReadModel readModel = do
  found <- lookupReadModel (readModel ^. #name)
  pure $ case found of
    Just metadata -> validateMetadata readModel metadata
    Nothing -> Left (ReadModelUnregistered (readModel ^. #name))

validateMetadata :: ReadModel q r -> ReadModelMetadata -> Either ReadModelError ()
validateMetadata readModel metadata
  | metadata ^. #version /= readModel ^. #version =
      stale
  | metadata ^. #shapeHash /= readModel ^. #shapeHash =
      stale
  | metadata ^. #status /= Live =
      Left (ReadModelNotLive (readModel ^. #name) (metadata ^. #status))
  | otherwise =
      Right ()
  where
    stale =
      Left
        ( ReadModelStaleSchema
            (readModel ^. #name)
            (readModel ^. #version)
            (metadata ^. #version)
            (readModel ^. #shapeHash)
            (metadata ^. #shapeHash)
        )

waitIfNeeded ::
  (IOE :> es, Store :> es) =>
  Maybe KeiroMetrics ->
  ConsistencyMode ->
  ReadModel q r ->
  Eff es (Either ReadModelError ())
waitIfNeeded metrics Strong readModel = do
  target <- case readModel ^. #strongScope of
    EntireLog -> storeHeadPosition
    CategoryHead category -> categoryHeadPosition category
  waitFor metrics (defaultStrongWaitOptions & #target ?~ target) readModel target
waitIfNeeded _ Eventual _ = pure (Right ())
waitIfNeeded metrics (PositionWait options) readModel =
  case options ^. #target of
    Nothing -> pure (Right ())
    Just targetPosition -> waitFor metrics options readModel targetPosition

readSubscriptionPosition ::
  (Store :> es) =>
  Text ->
  Eff es (Maybe GlobalPosition)
readSubscriptionPosition subscriptionName =
  runTransaction
    $ Tx.statement subscriptionName lookupSubscriptionPositionStmt

lookupSubscriptionPositionStmt :: Statement Text (Maybe GlobalPosition)
lookupSubscriptionPositionStmt =
  preparable
    """
    SELECT min(last_seen)
    FROM subscriptions
    WHERE subscription_name = $1
    """
    (E.param (E.nonNullable E.text))
    (D.singleRow (fmap GlobalPosition <$> D.column (D.nullable D.int8)))

-- | The global position of the most recent event in the @$all@ log, or
-- @GlobalPosition 0@ when the log is empty. 'readAllBackward' treats
-- @GlobalPosition 0@ as "after everything", so a limit of 1 returns the head.
storeHeadPosition :: (Store :> es) => Eff es GlobalPosition
storeHeadPosition = do
  recent <- readAllBackward (GlobalPosition 0) 1
  pure $ case Vector.toList recent of
    (event : _) -> event ^. #globalPosition
    [] -> GlobalPosition 0

-- | The latest global position originating in a Kiroku category, or
-- @GlobalPosition 0@ when that category has no events. This deliberately reads
-- Kiroku's indexed @streams@ and @$all@ membership tables because Kiroku 0.3 does
-- not export a category-head query.
categoryHeadPosition :: (Store :> es) => Text -> Eff es GlobalPosition
categoryHeadPosition category =
  runTransaction
    $ Tx.statement category categoryHeadPositionStmt

categoryHeadPositionStmt :: Statement Text GlobalPosition
categoryHeadPositionStmt =
  preparable
    """
    SELECT COALESCE(max(se.stream_version), 0)
    FROM streams s
    JOIN stream_events se
      ON se.original_stream_id = s.stream_id
     AND se.stream_id = 0
    WHERE s.category = $1
    """
    (E.param (E.nonNullable E.text))
    (D.singleRow (GlobalPosition <$> D.column (D.nonNullable D.int8)))