keiro-0.6.0.0: src/Keiro/Snapshot/Schema.hs
-- | The @keiro_snapshots@ table: persistence for aggregate snapshots.
--
-- One row per stream holds the latest snapshot of its folded state as JSONB,
-- tagged with the 'stateCodecVersion', 'regfileShapeHash', and 'stateShapeHash'
-- that produced it. 'lookupSnapshot' fetches the newest row matching all three
-- discriminators (so incompatible snapshots are simply not found). Within one
-- discriminator tuple, 'writeSnapshotRow' keeps only the highest stream version,
-- so a late or out-of-order write cannot regress the snapshot.
--
-- A write with a /different/ discriminator deliberately replaces the row even
-- at a lower stream version. This lets a rolled-back deployment
-- reclaim the single snapshot slot instead of being locked out by a newer codec
-- forever. During a mixed-version deployment, however, writers with incompatible
-- codecs can thrash that row and each side will miss the other's snapshot. The
-- cost is repeated full replay, never incorrect state: the event log remains the
-- source of truth.
--
-- This module is the storage layer beneath "Keiro.Snapshot"; callers normally
-- go through 'Keiro.Snapshot.hydrateWithSnapshot' and
-- 'Keiro.Snapshot.writeSnapshot' rather than these statements directly.
module Keiro.Snapshot.Schema
( -- * Rows
SnapshotRow (..),
SnapshotWrite (..),
-- * Storage
lookupSnapshot,
writeSnapshotRow,
)
where
import Contravariant.Extras (contrazip4, contrazip6)
import Effectful (Eff, (:>))
import Hasql.Decoders qualified as D
import Hasql.Encoders qualified as E
import Hasql.Statement (Statement, preparable)
import Keiro.Prelude
import Kiroku.Store.Effect (Store)
import Kiroku.Store.Transaction (runTransaction)
import Kiroku.Store.Types (StreamId (..), StreamVersion (..))
import "hasql-transaction" Hasql.Transaction qualified as Tx
import Prelude qualified
-- | A snapshot row as read back from @keiro_snapshots@: the stored 'state'
-- JSON, the 'streamVersion' it captures, the 'stateCodecVersion' and
-- 'regfileShapeHash' and 'stateShapeHash' that gate compatibility, and the create/update
-- timestamps.
data SnapshotRow = SnapshotRow
{ streamId :: !StreamId,
streamVersion :: !StreamVersion,
state :: !Value,
stateCodecVersion :: !Int,
regfileShapeHash :: !Text,
stateShapeHash :: !Text,
createdAt :: !UTCTime,
updatedAt :: !UTCTime
}
deriving stock (Generic, Eq, Show)
-- | The fields needed to write a snapshot — 'SnapshotRow' minus the
-- database-managed timestamps.
data SnapshotWrite = SnapshotWrite
{ streamId :: !StreamId,
streamVersion :: !StreamVersion,
state :: !Value,
stateCodecVersion :: !Int,
regfileShapeHash :: !Text,
stateShapeHash :: !Text
}
deriving stock (Generic, Eq, Show)
-- | Fetch the latest snapshot for a stream that matches all three codec
-- discriminators. Returns 'Nothing' when no compatible snapshot exists, so an
-- incompatible one is treated as absent.
lookupSnapshot ::
(Store :> es) =>
StreamId ->
Int ->
Text ->
Text ->
Eff es (Maybe SnapshotRow)
lookupSnapshot streamId version shapeHash stateShapeHash =
runTransaction
$ Tx.statement
(streamIdToInt streamId, Prelude.fromIntegral version, shapeHash, stateShapeHash)
lookupSnapshotStmt
-- | Upsert a snapshot row for its stream. For the same discriminator tuple,
-- the write only takes effect when its 'streamVersion' is at least the stored
-- one. Any incompatible discriminator replaces the row even at a lower version
-- so codec rollback can make progress; see the module header for the
-- mixed-deployment performance caveat.
writeSnapshotRow ::
(Store :> es) =>
SnapshotWrite ->
Eff es ()
writeSnapshotRow snapshot =
runTransaction
$ Tx.statement (snapshotWriteParams snapshot) writeSnapshotStmt
lookupSnapshotStmt :: Statement (Int64, Int64, Text, Text) (Maybe SnapshotRow)
lookupSnapshotStmt =
preparable
"""
SELECT stream_id, stream_version, state, state_codec_version, regfile_shape_hash, state_shape_hash, created_at, updated_at
FROM keiro.keiro_snapshots
WHERE stream_id = $1
AND state_codec_version = $2
AND regfile_shape_hash = $3
AND state_shape_hash = $4
ORDER BY stream_version DESC
LIMIT 1
"""
( contrazip4
(E.param (E.nonNullable E.int8))
(E.param (E.nonNullable E.int8))
(E.param (E.nonNullable E.text))
(E.param (E.nonNullable E.text))
)
(D.rowMaybe snapshotRowDecoder)
writeSnapshotStmt :: Statement (Int64, Int64, Value, Int64, Text, Text) ()
writeSnapshotStmt =
preparable
"""
INSERT INTO keiro.keiro_snapshots
(stream_id, stream_version, state, state_codec_version, regfile_shape_hash, state_shape_hash)
VALUES
($1, $2, $3, $4, $5, $6)
ON CONFLICT (stream_id) DO UPDATE
SET stream_version = EXCLUDED.stream_version,
state = EXCLUDED.state,
state_codec_version = EXCLUDED.state_codec_version,
regfile_shape_hash = EXCLUDED.regfile_shape_hash,
state_shape_hash = EXCLUDED.state_shape_hash,
updated_at = now()
WHERE keiro_snapshots.stream_version <= EXCLUDED.stream_version
OR keiro_snapshots.state_codec_version <> EXCLUDED.state_codec_version
OR keiro_snapshots.regfile_shape_hash <> EXCLUDED.regfile_shape_hash
OR keiro_snapshots.state_shape_hash <> EXCLUDED.state_shape_hash
"""
( contrazip6
(E.param (E.nonNullable E.int8))
(E.param (E.nonNullable E.int8))
(E.param (E.nonNullable E.jsonb))
(E.param (E.nonNullable E.int8))
(E.param (E.nonNullable E.text))
(E.param (E.nonNullable E.text))
)
D.noResult
snapshotRowDecoder :: D.Row SnapshotRow
snapshotRowDecoder =
SnapshotRow
<$> (StreamId <$> D.column (D.nonNullable D.int8))
<*> (StreamVersion <$> D.column (D.nonNullable D.int8))
<*> D.column (D.nonNullable D.jsonb)
<*> (Prelude.fromIntegral <$> D.column (D.nonNullable D.int8))
<*> D.column (D.nonNullable D.text)
<*> D.column (D.nonNullable D.text)
<*> D.column (D.nonNullable D.timestamptz)
<*> D.column (D.nonNullable D.timestamptz)
streamIdToInt :: StreamId -> Int64
streamIdToInt (StreamId value) = value
streamVersionToInt :: StreamVersion -> Int64
streamVersionToInt (StreamVersion value) = value
snapshotWriteParams :: SnapshotWrite -> (Int64, Int64, Value, Int64, Text, Text)
snapshotWriteParams snapshot =
( streamIdToInt (snapshot ^. #streamId),
streamVersionToInt (snapshot ^. #streamVersion),
snapshot ^. #state,
Prelude.fromIntegral (snapshot ^. #stateCodecVersion),
snapshot ^. #regfileShapeHash,
snapshot ^. #stateShapeHash
)