keiro-0.2.0.0: src/Keiro/Snapshot.hs
{- | Snapshots: skipping replay by persisting folded aggregate state.
A snapshot stores an aggregate's @(state, registers)@ at a known stream
version so hydration can start there instead of replaying the whole event
log. 'hydrateWithSnapshot' loads the latest compatible snapshot for a
stream (returning a 'SnapshotSeed' the command runner replays /forward/
from), and 'writeSnapshot' persists one after an append when the stream's
'Keiro.EventStream.SnapshotPolicy' fires.
Compatibility is gated by the version and register-file shape hash of the
'StateCodec': a snapshot is only loaded when both match the current codec, so a
change to the snapshot encoding or to the register layout transparently
falls back to a full replay rather than decoding stale bytes. The JSON
encoding lives in "Keiro.Snapshot.Codec" and the SQL storage in
"Keiro.Snapshot.Schema", both re-exported here.
Snapshot version non-regression applies only while the codec version and shape
hash stay the same. A writer with either discriminant changed may replace a
higher-version row, allowing codec rollback to recover. In a mixed-version
deployment incompatible writers can therefore thrash the one row per stream
and repeatedly force full replay; this affects performance, not correctness.
-}
module Keiro.Snapshot (
-- * Hydration seed
SnapshotSeed (..),
SnapshotMissReason (..),
SnapshotLookup (..),
lookupSnapshotSeed,
hydrateWithSnapshot,
encodeSnapshotStrict,
writeSnapshotEncoded,
writeSnapshot,
-- * State codec and storage
module Keiro.Snapshot.Codec,
module Keiro.Snapshot.Schema,
)
where
import Control.DeepSeq (force)
import Control.Exception (ErrorCall, evaluate, try)
import Effectful (Eff, (:>))
import Keiki.Core (RegFile)
import Keiro.EventStream (StateCodec)
import Keiro.Prelude
import Keiro.Snapshot.Codec
import Keiro.Snapshot.Schema
import Kiroku.Store.Effect (Store)
import Kiroku.Store.Read (lookupStreamId)
import Kiroku.Store.Types (StreamId, StreamName, StreamVersion)
{- | A decoded snapshot the command runner replays forward from: the folded
'state' and 'registers' as of 'streamVersion'. Hydration reads only the
events after 'streamVersion' instead of the entire stream.
-}
data SnapshotSeed rs s = SnapshotSeed
{ state :: !s
, registers :: !(RegFile rs)
, streamVersion :: !StreamVersion
}
deriving stock (Generic)
-- | Why a snapshot lookup produced no usable hydration seed.
data SnapshotMissReason
= SnapshotNoStream
| SnapshotNotFound
| SnapshotDecodeFailed !Text
deriving stock (Eq, Show, Generic)
-- | The observable result of looking up and decoding an aggregate snapshot.
data SnapshotLookup rs s
= SnapshotUnavailable !SnapshotMissReason
| SnapshotHit !(SnapshotSeed rs s)
deriving stock (Generic)
{- | Look up the latest compatible snapshot and retain the reason when no
usable seed exists. A matching row that fails to decode is distinguished from
a missing stream or row so callers can report the persistent fallback.
-}
lookupSnapshotSeed ::
(Store :> es) =>
StreamName ->
StateCodec (s, RegFile rs) ->
Eff es (SnapshotLookup rs s)
lookupSnapshotSeed streamName codec = do
streamId <- lookupStreamId streamName
case streamId of
Nothing -> pure (SnapshotUnavailable SnapshotNoStream)
Just foundStreamId -> do
row <- lookupSnapshot foundStreamId (codec ^. #stateCodecVersion) (codec ^. #shapeHash)
pure $ case row of
Nothing -> SnapshotUnavailable SnapshotNotFound
Just snapshot ->
case (codec ^. #decode) (snapshot ^. #state) of
Left message -> SnapshotUnavailable (SnapshotDecodeFailed message)
Right (state, registers) ->
SnapshotHit
SnapshotSeed
{ state = state
, registers = registers
, streamVersion = snapshot ^. #streamVersion
}
{- | Load the latest snapshot compatible with @codec@ for the named stream.
Returns 'Nothing' — meaning "replay from the beginning" — when the stream
has no id yet, has no snapshot at the codec's version and shape hash, or has
a snapshot whose bytes fail to decode. Decode failure is treated as a benign
miss rather than an error, so a corrupt or stale snapshot never blocks
hydration.
-}
hydrateWithSnapshot ::
(Store :> es) =>
StreamName ->
StateCodec (s, RegFile rs) ->
Eff es (Maybe (SnapshotSeed rs s))
hydrateWithSnapshot streamName codec = do
lookupSnapshotSeed streamName codec <&> \case
SnapshotUnavailable _ -> Nothing
SnapshotHit seed -> Just seed
{- | Strictly encode @state@ with @codec@, forcing the complete JSON value and
returning an 'ErrorCall' raised by a partial encoder or an uninitialized keiki
register. Other exception types deliberately remain visible to the caller.
-}
encodeSnapshotStrict :: StateCodec state -> state -> IO (Either ErrorCall Value)
encodeSnapshotStrict codec state =
try @ErrorCall (evaluate (force ((codec ^. #encode) state)))
{- | Upsert a JSON value that has already been encoded and forced. Keeping this
separate from 'writeSnapshot' lets post-commit callers prove encoding is safe
before they touch the store. For a fixed codec version and shape hash, stale
versions are ignored. A different codec version or shape hash may replace a
higher-version row to permit codec rollback; see the module header.
-}
writeSnapshotEncoded ::
(Store :> es) =>
StreamId ->
StreamVersion ->
StateCodec state ->
Value ->
Eff es ()
writeSnapshotEncoded streamId streamVersion codec encoded =
writeSnapshotRow
SnapshotWrite
{ streamId = streamId
, streamVersion = streamVersion
, state = encoded
, stateCodecVersion = codec ^. #stateCodecVersion
, regfileShapeHash = codec ^. #shapeHash
}
{- | Encode @state@ with @codec@ and upsert it as the snapshot for the given
stream at @streamVersion@. This compatibility helper preserves the historical
lazy encoding behavior; post-commit advisory paths should call
'encodeSnapshotStrict' first and pass the result to 'writeSnapshotEncoded'. For
a fixed codec version and shape hash stale writes are ignored, while an
incompatible codec may replace a newer row to permit rollback.
-}
writeSnapshot ::
(Store :> es) =>
StreamId ->
StreamVersion ->
StateCodec state ->
state ->
Eff es ()
writeSnapshot streamId streamVersion codec state =
writeSnapshotEncoded streamId streamVersion codec ((codec ^. #encode) state)