keiro-0.22.0.0: src/Keiro/Inspection/Aggregate.hs
{-# LANGUAGE GADTs #-}
{-# LANGUAGE OverloadedRecordDot #-}
-- | Application-owned aggregate descriptions and read-only inspection.
-- Raw history belongs to Kiroku; these reads return folded state and references.
module Keiro.Inspection.Aggregate
( AggregateInspector (..),
SomeAggregateInspector (..),
AggregateInspectors,
aggregateInspector,
inspectorFromAuditTarget,
displayViaStateCodec,
aggregateInspectors,
inspectorName,
AggregateTypeSummary (..),
listAggregateTypes,
AggregateInspectionError (..),
aggregateStream,
aggregateIdOf,
SnapshotCompatibility (..),
SnapshotConfig (..),
StoredSnapshot (..),
SnapshotStatus (..),
HistoryReference (..),
AggregateView (..),
inspectAggregate,
snapshotStatusFor,
AggregateListRequest (..),
AggregateSummary (..),
AggregateListPage (..),
maxAggregatePageSize,
aggregateListCursorKind,
listAggregates,
aggregateTypeJson,
hydrationSourceJson,
snapshotStatusJson,
aggregateViewJson,
aggregateSummaryJson,
aggregateListPageJson,
inspectionErrorText,
)
where
import Data.Aeson (object, (.=))
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Maybe (mapMaybe)
import Data.Text qualified as Text
import Data.Time (diffUTCTime)
import Data.Vector qualified as Vector
import Effectful (Eff, IOE, (:>))
import Keiki.Core (BoolAlg, RegFile)
import Keiro.Command
import Keiro.EventStream (EventStream, SnapshotPolicy (..), StateCodec)
import Keiro.EventStream.Validate (ValidatedEventStream, unvalidated)
import Keiro.Inspection.Cursor
import Keiro.Prelude hiding ((.=))
import Keiro.ReplayAudit (AuditTarget, streamInCategory)
import Keiro.Snapshot (SnapshotMissReason (..))
import Keiro.Snapshot.Schema qualified as Snapshot
import Keiro.Stream (CategoryError, Stream, StreamCategory, categoryName, categoryText, entityStream)
import Keiro.Stream qualified as Stream
import Kiroku.Store.Effect (Store)
import Kiroku.Store.Read (getStream, listStreams)
import Kiroku.Store.Types (StreamInfo (..), StreamName (..), StreamVersion (..), mkBrowsePageSize)
data AggregateInspector phi rs s ci co = AggregateInspector
{ name :: !Text,
eventStream :: !(ValidatedEventStream phi rs s ci co),
category :: !(StreamCategory (EventStream phi rs s ci co)),
mkStream :: !(StreamName -> Maybe (Stream (EventStream phi rs s ci co))),
displayState :: !(s -> RegFile rs -> Value),
stateLabel :: !(s -> Text)
}
deriving stock (Generic)
aggregateInspector :: (Show s) => Text -> ValidatedEventStream phi rs s ci co -> StreamCategory (EventStream phi rs s ci co) -> (s -> RegFile rs -> Value) -> AggregateInspector phi rs s ci co
aggregateInspector name eventStream cat displayState =
AggregateInspector name eventStream cat (streamInCategory (categoryText cat)) displayState (Text.pack . show)
inspectorFromAuditTarget :: (Show s) => Text -> AuditTarget phi rs s ci co -> (s -> RegFile rs -> Value) -> Either CategoryError (AggregateInspector phi rs s ci co)
inspectorFromAuditTarget name target display = do
cat <- Stream.category (target ^. #category)
pure ((aggregateInspector name (target ^. #eventStream) cat display) {mkStream = target ^. #mkStream})
displayViaStateCodec :: StateCodec (s, RegFile rs) -> s -> RegFile rs -> Value
displayViaStateCodec codec state registers = (codec ^. #encode) (state, registers)
data SomeAggregateInspector where
SomeAggregateInspector :: (BoolAlg phi (RegFile rs, ci), Eq co) => AggregateInspector phi rs s ci co -> SomeAggregateInspector
type AggregateInspectors = Map Text SomeAggregateInspector
inspectorName :: SomeAggregateInspector -> Text
inspectorName (SomeAggregateInspector inspector) = inspector.name
aggregateInspectors :: [SomeAggregateInspector] -> Either Text AggregateInspectors
aggregateInspectors = go Map.empty
where
go acc [] = Right acc
go acc (inspector : rest)
| Map.member (inspectorName inspector) acc = Left ("duplicate aggregate inspector: " <> inspectorName inspector)
| otherwise = go (Map.insert (inspectorName inspector) inspector acc) rest
data AggregateTypeSummary = AggregateTypeSummary
{name :: !Text, category :: !Text, snapshotsConfigured :: !Bool, snapshotPolicy :: !Text}
deriving stock (Eq, Show, Generic)
policyLabel :: SnapshotPolicy s -> Text
policyLabel = \case Never -> "never"; Every n -> "every:" <> Text.pack (show n); OnTerminal -> "on_terminal"; Custom _ -> "custom"
listAggregateTypes :: AggregateInspectors -> [AggregateTypeSummary]
listAggregateTypes = map describe . Map.elems
where
describe :: SomeAggregateInspector -> AggregateTypeSummary
describe (SomeAggregateInspector inspector) =
let definition = unvalidated inspector.eventStream
in AggregateTypeSummary inspector.name (categoryText inspector.category) (isJust (definition ^. #stateCodec)) (policyLabel (definition ^. #snapshotPolicy))
data AggregateInspectionError
= AggregateNotFound !StreamName
| AggregateIdRejected !Text
| AggregateHydrationFailed !StreamName !CommandError
| InvalidPageSize !Int
| InvalidCursor !CursorError
| ResultTooLarge
deriving stock (Eq, Show, Generic)
aggregateStream :: AggregateInspector phi rs s ci co -> Text -> Either AggregateInspectionError (Stream (EventStream phi rs s ci co))
aggregateStream inspector aggregateId
| Text.null (Text.strip aggregateId) = Left (AggregateIdRejected aggregateId)
| otherwise =
let target = entityStream inspector.category aggregateId
in case inspector.mkStream (Stream.streamName target) of
Nothing -> Left (AggregateIdRejected aggregateId)
Just accepted | inspectorStreamName inspector accepted == Stream.streamName target -> Right accepted
_ -> Left (AggregateIdRejected aggregateId)
-- Metadata, membership and hydration must name the same physical stream even
-- when an audit target maps a physical name to a different typed handle.
inspectorStreamName :: AggregateInspector phi rs s ci co -> Stream (EventStream phi rs s ci co) -> StreamName
inspectorStreamName inspector target = (unvalidated inspector.eventStream ^. #resolveStreamName) target
aggregateIdOf :: AggregateInspector phi rs s ci co -> StreamName -> Maybe Text
aggregateIdOf inspector name@(StreamName raw) = do
accepted <- inspector.mkStream name
if inspectorStreamName inspector accepted /= name
then Nothing
else do
suffix <- Text.stripPrefix (categoryText inspector.category <> "-") raw
if Text.null (Text.strip suffix) then Nothing else Just suffix
data SnapshotCompatibility = Compatible | Incompatible ![Text] | Unused
deriving stock (Eq, Show, Generic)
data SnapshotConfig = SnapshotConfig
{policy :: !Text, stateCodecVersion :: !Int, regfileShapeHash :: !Text, stateShapeHash :: !Text}
deriving stock (Eq, Show, Generic)
data StoredSnapshot = StoredSnapshot
{ aheadOfObservedHead :: !Bool,
streamVersion :: !StreamVersion,
createdAt :: !UTCTime,
updatedAt :: !UTCTime,
ageSeconds :: !Double,
eventsSince :: !Int64,
stateCodecVersion :: !Int,
regfileShapeHash :: !Text,
stateShapeHash :: !Text,
compatibility :: !SnapshotCompatibility
}
deriving stock (Eq, Show, Generic)
data SnapshotStatus = SnapshotStatus
{observedAt :: !UTCTime, configured :: !(Maybe SnapshotConfig), stored :: !(Maybe StoredSnapshot)}
deriving stock (Eq, Show, Generic)
data HistoryReference = HistoryReference
{owner :: !Text, streamName :: !StreamName, headVersion :: !StreamVersion}
deriving stock (Eq, Show, Generic)
data AggregateView = AggregateView
{ name :: !Text,
category :: !Text,
aggregateId :: !Text,
streamName :: !StreamName,
streamVersion :: !StreamVersion,
createdAt :: !UTCTime,
deletedAt :: !(Maybe UTCTime),
truncateBefore :: !StreamVersion,
hydration :: !(Maybe HydrationSource),
stateLabel :: !(Maybe Text),
state :: !(Maybe Value),
snapshot :: !SnapshotStatus,
history :: !HistoryReference
}
deriving stock (Eq, Show, Generic)
snapshotStatusOf :: (Store :> es) => AggregateInspector phi rs s ci co -> StreamInfo -> Eff es SnapshotStatus
snapshotStatusOf inspector info = do
observation <- Snapshot.observeSnapshotRow info.id
let definition = unvalidated inspector.eventStream
configured = fmap (\codec -> SnapshotConfig (policyLabel (definition ^. #snapshotPolicy)) (codec ^. #stateCodecVersion) (codec ^. #shapeHash) (codec ^. #stateShapeHash)) (definition ^. #stateCodec)
stored =
fmap
( \row ->
StoredSnapshot
(row.streamVersion > info.version)
row.streamVersion
row.createdAt
row.updatedAt
(realToFrac (diffUTCTime observation.observedAt row.updatedAt))
(max 0 (versionInt info.version - versionInt row.streamVersion))
row.stateCodecVersion
row.regfileShapeHash
row.stateShapeHash
(compatibilityOf configured row)
)
observation.row
pure SnapshotStatus {observedAt = observation.observedAt, configured, stored}
where
compatibilityOf :: Maybe SnapshotConfig -> Snapshot.SnapshotRow -> SnapshotCompatibility
compatibilityOf Nothing _ = Unused
compatibilityOf (Just config) row =
case [key | (key, differs) <- [("state_codec_version", config.stateCodecVersion /= row.stateCodecVersion), ("regfile_shape_hash", config.regfileShapeHash /= row.regfileShapeHash), ("state_shape_hash", config.stateShapeHash /= row.stateShapeHash)], differs] of
[] -> Compatible
keys -> Incompatible keys
snapshotStatusFor :: (Store :> es) => AggregateInspector phi rs s ci co -> Text -> Eff es (Either AggregateInspectionError (StreamName, StreamVersion, SnapshotStatus))
snapshotStatusFor inspector aggregateId =
case aggregateStream inspector aggregateId of
Left err -> pure (Left err)
Right target -> do
info <- getStream (inspectorStreamName inspector target)
case info of
Nothing -> pure (Left (AggregateNotFound (inspectorStreamName inspector target)))
Just found -> do
status <- snapshotStatusOf inspector found
pure (Right (found.name, found.version, status))
inspectAggregate :: (IOE :> es, Store :> es, BoolAlg phi (RegFile rs, ci), Eq co) => AggregateInspector phi rs s ci co -> Text -> Eff es (Either AggregateInspectionError AggregateView)
inspectAggregate inspector aggregateId =
case aggregateStream inspector aggregateId of
Left err -> pure (Left err)
Right target ->
getStream (inspectorStreamName inspector target) >>= \case
Nothing -> pure (Left (AggregateNotFound (inspectorStreamName inspector target)))
Just info -> do
folded <- if isJust info.deletedAt then pure (Right Nothing) else fmap (fmap Just) (hydrateWithSource inspectionRunCommandOptions (unvalidated inspector.eventStream) target)
case folded of
Left err -> pure (Left (AggregateHydrationFailed info.name err))
Right result -> do
snapshot <- snapshotStatusOf inspector info
pure
( Right
AggregateView
{ name = inspector.name,
category = categoryText inspector.category,
aggregateId,
streamName = info.name,
streamVersion = maybe info.version (\(hydrated, _) -> hydrated ^. #streamVersion) result,
createdAt = info.createdAt,
deletedAt = info.deletedAt,
truncateBefore = info.truncateBefore,
hydration = fmap snd result,
stateLabel = fmap (\(hydrated, _) -> inspector.stateLabel (hydrated ^. #state)) result,
state = fmap (\(hydrated, _) -> inspector.displayState (hydrated ^. #state) (hydrated ^. #registers)) result,
snapshot,
history = HistoryReference "kiroku" info.name info.version
}
)
data AggregateListRequest = AggregateListRequest {pageSize :: !Int, after :: !(Maybe InspectionCursor)}
deriving stock (Eq, Show, Generic)
data AggregateSummary = AggregateSummary
{ aggregateId :: !Text,
streamName :: !StreamName,
streamVersion :: !StreamVersion,
createdAt :: !UTCTime,
deletedAt :: !(Maybe UTCTime),
truncateBefore :: !StreamVersion
}
deriving stock (Eq, Show, Generic)
data AggregateListPage = AggregateListPage
{name :: !Text, category :: !Text, items :: ![AggregateSummary], skipped :: !Int, next :: !(Maybe InspectionCursor)}
deriving stock (Eq, Show, Generic)
maxAggregatePageSize :: Int
maxAggregatePageSize = 500
aggregateListCursorKind :: Text
aggregateListCursorKind = "aggregate-list"
listAggregates :: (Store :> es) => AggregateInspector phi rs s ci co -> AggregateListRequest -> Eff es (Either AggregateInspectionError AggregateListPage)
listAggregates inspector request =
case parameters of
Left err -> pure (Left err)
Right (after, limit) -> do
raw <- listStreams (Just (categoryName inspector.category)) (Just (categoryText inspector.category <> "-")) after limit
let examined = Vector.toList (Vector.take request.pageSize raw)
items = mapMaybe summary examined
continuation =
if Vector.length raw > request.pageSize
then case reverse examined of
lastRow : _ -> case lastRow.name of StreamName lastName -> maybe (Left ResultTooLarge) (Right . Just) (encodeCursorBounded aggregateListCursorKind (inspector.name, categoryText inspector.category, lastName))
[] -> Right Nothing
else Right Nothing
pure $ fmap (\next -> AggregateListPage inspector.name (categoryText inspector.category) items (length examined - length items) next) continuation
where
parameters
| request.pageSize < 1 || request.pageSize > maxAggregatePageSize = Left (InvalidPageSize request.pageSize)
| otherwise = do
limit <- either (const (Left (InvalidPageSize request.pageSize))) Right (mkBrowsePageSize (request.pageSize + 1))
after <- traverse decodeAfter request.after
pure (after, limit)
decodeAfter :: InspectionCursor -> Either AggregateInspectionError StreamName
decodeAfter cursor = do
(name, cat, lastName) <- either (Left . InvalidCursor) Right (decodeCursor aggregateListCursorKind cursor)
if name == inspector.name && cat == categoryText inspector.category && Text.isPrefixOf (cat <> "-") lastName
then Right (StreamName lastName)
else Left (InvalidCursor (MalformedCursor "cursor belongs to another aggregate inspector"))
summary :: StreamInfo -> Maybe AggregateSummary
summary info = do
aggregateId <- aggregateIdOf inspector info.name
pure (AggregateSummary aggregateId info.name info.version info.createdAt info.deletedAt info.truncateBefore)
versionInt :: StreamVersion -> Int64
versionInt (StreamVersion n) = n
nameText :: StreamName -> Text
nameText (StreamName name) = name
aggregateTypeJson :: AggregateTypeSummary -> Value
aggregateTypeJson summary = object ["name" .= summary.name, "category" .= summary.category, "snapshots_configured" .= summary.snapshotsConfigured, "snapshot_policy" .= summary.snapshotPolicy]
hydrationSourceJson :: HydrationSource -> Value
hydrationSourceJson = \case
FromSnapshot version -> object ["source" .= ("snapshot" :: Text), "snapshot_version" .= versionInt version]
FullReplay reason ->
object
( ["source" .= ("full_replay" :: Text)] <> case reason of
NoStateCodec -> ["reason" .= ("no_state_codec" :: Text)]
SnapshotMissed miss ->
["reason" .= ("snapshot_missed" :: Text)] <> case miss of
SnapshotNoStream -> ["miss" .= ("no_stream" :: Text)]
SnapshotNotFound -> ["miss" .= ("not_found" :: Text)]
SnapshotDecodeFailed detail -> ["miss" .= ("decode_failed" :: Text), "detail" .= detail]
SeededReplayFailed err -> ["reason" .= ("seeded_replay_failed" :: Text), "error" .= commandErrorClass err, "detail" .= Text.pack (show err)]
)
snapshotStatusJson :: SnapshotStatus -> Value
snapshotStatusJson status =
object
[ "status" .= (if isJust status.stored then "present" else "absent" :: Text),
"observed_at" .= status.observedAt,
"configured" .= fmap configJson status.configured,
"stored" .= fmap storedJson status.stored
]
where
configJson :: SnapshotConfig -> Value
configJson config = object ["policy" .= config.policy, "state_codec_version" .= config.stateCodecVersion, "regfile_shape_hash" .= config.regfileShapeHash, "state_shape_hash" .= config.stateShapeHash]
storedJson :: StoredSnapshot -> Value
storedJson stored =
object
[ "stream_version" .= versionInt stored.streamVersion,
"created_at" .= stored.createdAt,
"updated_at" .= stored.updatedAt,
"age_seconds" .= stored.ageSeconds,
"events_since" .= stored.eventsSince,
"ahead_of_observed_head" .= stored.aheadOfObservedHead,
"state_codec_version" .= stored.stateCodecVersion,
"regfile_shape_hash" .= stored.regfileShapeHash,
"state_shape_hash" .= stored.stateShapeHash,
"compatibility" .= (case stored.compatibility of Compatible -> "compatible"; Incompatible _ -> "incompatible"; Unused -> "unused" :: Text),
"mismatched" .= (case stored.compatibility of Incompatible fields -> fields; _ -> [])
]
aggregateViewJson :: AggregateView -> Value
aggregateViewJson view =
object
[ "name" .= view.name,
"category" .= view.category,
"aggregate_id" .= view.aggregateId,
"stream_name" .= nameText view.streamName,
"stream_version" .= versionInt view.streamVersion,
"state_version" .= (if isJust view.state then Just (versionInt view.streamVersion) else Nothing),
"created_at" .= view.createdAt,
"deleted_at" .= view.deletedAt,
"truncate_before" .= versionInt view.truncateBefore,
"hydration" .= fmap hydrationSourceJson view.hydration,
"state_label" .= view.stateLabel,
"state" .= view.state,
"snapshot" .= snapshotStatusJson view.snapshot,
"history" .= object ["owner" .= view.history.owner, "stream_name" .= nameText view.history.streamName, "head_version" .= versionInt view.history.headVersion]
]
aggregateSummaryJson :: AggregateSummary -> Value
aggregateSummaryJson summary =
object
[ "aggregate_id" .= summary.aggregateId,
"stream_name" .= nameText summary.streamName,
"stream_version" .= versionInt summary.streamVersion,
"created_at" .= summary.createdAt,
"deleted_at" .= summary.deletedAt,
"truncate_before" .= versionInt summary.truncateBefore
]
aggregateListPageJson :: AggregateListPage -> Value
aggregateListPageJson page =
object
( ["name" .= page.name, "category" .= page.category, "items" .= map aggregateSummaryJson page.items, "skipped" .= page.skipped]
<> maybe [] (\cursor -> ["next_cursor" .= cursor.cursorText]) page.next
)
inspectionErrorText :: AggregateInspectionError -> Text
inspectionErrorText = \case
AggregateNotFound name -> "aggregate not found: " <> nameText name
AggregateIdRejected aggregateId -> "aggregate id rejected: " <> aggregateId
AggregateHydrationFailed name err -> "aggregate hydration failed: " <> nameText name <> ": " <> Text.pack (show err)
InvalidPageSize n -> "page size must be 1..500: " <> Text.pack (show n)
InvalidCursor err -> "invalid aggregate cursor: " <> Text.pack (show err)
ResultTooLarge -> "aggregate continuation exceeds the cursor byte limit"