keiro-0.2.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)))