kiroku-store-0.3.0.0: src/Kiroku/Store/Effect.hs
{-# LANGUAGE TypeFamilies #-}
module Kiroku.Store.Effect (
-- * The Store effect
Store (..),
-- * Interpreters
runStorePool,
runStoreResource,
runStoreIO,
-- * Internal building blocks
--
-- $internal
PreparedEvent,
prepareEvents,
buildAppendParams,
appendDispatchTx,
) where
import Control.Lens ((^.))
import Control.Monad.Except qualified as Except
import Control.Monad.IO.Class (MonadIO, liftIO)
import Data.Aeson (Value)
import Data.Foldable (for_)
import Data.Generics.Labels ()
import Data.Int (Int32, Int64)
import Data.List (find)
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Maybe (isNothing)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Time.Clock (UTCTime, getCurrentTime)
import Data.UUID (UUID)
import Data.UUID.V7 qualified as V7
import Data.Vector (Vector)
import Data.Vector qualified as V
import Effectful (Dispatch (..), DispatchOf, Eff, Effect, IOE, runEff, (:>))
import Effectful.Dispatch.Dynamic (interpret_)
import Effectful.Error.Static (Error, runErrorNoCallStack, throwError)
import GHC.Generics (Generic)
import Hasql.Decoders qualified as D
import Hasql.Encoders qualified as E
import Hasql.Pipeline qualified as Pipeline
import Hasql.Pool (Pool)
import Hasql.Pool qualified as Pool
import Hasql.Session qualified as Session
import Hasql.Statement (Statement, unpreparable)
import Hasql.Transaction qualified as Tx
import Hasql.Transaction.Sessions qualified as TxSessions
import Kiroku.Store.Connection (KirokuStore (..))
import Kiroku.Store.Effect.Resource (KirokuStoreResource, getKirokuStore)
import Kiroku.Store.Error (StoreError (..), attributeMultiStreamError, emptyResultError, isTransientSerializationError, mapLinkUsageError, mapTransactionUsageError, mapUsageError, validateStreamName)
import Kiroku.Store.Observability (KirokuEvent (..))
import Kiroku.Store.SQL qualified as SQL
import Kiroku.Store.Settings (decodeEvents, enrichEvents)
import Kiroku.Store.Types
-- ---------------------------------------------------------------------------
-- Store effect
-- ---------------------------------------------------------------------------
-- | The Store effect — dynamically dispatched, mockable.
data Store :: Effect where
AppendToStream :: StreamName -> ExpectedVersion -> [EventData] -> Store m AppendResult
ReadStreamForward :: StreamName -> StreamVersion -> Int32 -> Store m (Vector RecordedEvent)
ReadStreamBackward :: StreamName -> StreamVersion -> Int32 -> Store m (Vector RecordedEvent)
ReadAllForward :: GlobalPosition -> Int32 -> Store m (Vector RecordedEvent)
ReadAllBackward :: GlobalPosition -> Int32 -> Store m (Vector RecordedEvent)
GetStream :: StreamName -> Store m (Maybe StreamInfo)
{- | Resolve a 'StreamName' to its surrogate 'StreamId' without
materializing the full 'StreamInfo' row. Mirrors 'GetStream'\'s
soft-delete semantics: returns 'Just' for both live and soft-deleted
streams, 'Nothing' for hard-deleted or never-created streams.
Cheaper than 'GetStream' — decodes one @int8@ column instead of five.
Surfaced as 'Kiroku.Store.Read.lookupStreamId'.
-}
LookupStreamId :: StreamName -> Store m (Maybe StreamId)
{- | Check whether an event id exists in a live stream without materializing
stream events. Soft-deleted streams behave as nonexistent, mirroring
'Kiroku.Store.Read.readStreamForward'.
Surfaced as 'Kiroku.Store.Read.eventExistsInStream'.
-}
EventExistsInStream :: StreamName -> EventId -> Store m Bool
{- | Resolve a batch of surrogate 'StreamId's to their 'StreamName's in a
single round trip. The result 'Map' contains an entry only for ids that
name an existing stream (live or soft-deleted); hard-deleted or unknown ids
are absent.
The primary use is recovering source stream names from the
'RecordedEvent.originalStreamId' of events obtained via fan-in reads
(@$all@, categories, causation/correlation queries, subscriptions): collect
the distinct ids from a batch and resolve them once, rather than per event.
Surfaced as 'Kiroku.Store.Read.lookupStreamNames' (and the singular
'Kiroku.Store.Read.lookupStreamName').
-}
LookupStreamNames :: [StreamId] -> Store m (Map StreamId StreamName)
LinkToStream :: StreamName -> [EventId] -> Store m LinkResult
ReadCategoryForward :: CategoryName -> GlobalPosition -> Int32 -> Store m (Vector RecordedEvent)
AppendMultiStream :: [(StreamName, ExpectedVersion, [EventData])] -> Store m [AppendResult]
{- | Fetch a set of 'RecordedEvent' rows that match an 'EventFilter'.
Surfaced to consumers as the smart constructors in
"Kiroku.Store.Causation": 'Kiroku.Store.Causation.findByCorrelation',
'Kiroku.Store.Causation.findCausationDescendants', and
'Kiroku.Store.Causation.findCausationAncestors'.
The filter is a closed sum ('EventFilter'); mock interpreters can
pattern-match exhaustively.
-}
FindEvents :: EventFilter -> Store m (Vector RecordedEvent)
SoftDeleteStream :: StreamName -> Store m (Maybe StreamId)
HardDeleteStream :: StreamName -> Store m (Maybe StreamId)
UndeleteStream :: StreamName -> Store m (Maybe StreamId)
{- | Set a stream's logical truncate-before marker. Per-stream ordered
reads then return only events whose version is >= the marker; the @$all@
global log, category reads, and subscriptions are unaffected. Reversible.
Surfaced as 'Kiroku.Store.Lifecycle.setStreamTruncateBefore' /
'Kiroku.Store.Lifecycle.clearStreamTruncateBefore'.
-}
SetStreamTruncateBefore :: StreamName -> StreamVersion -> Store m (Maybe StreamId)
{- | Run an arbitrary @hasql-transaction@ value in a 'BEGIN'/'COMMIT'
block on a single pool connection. Escape hatch from the abstract
'Store' effect into the underlying SQL world; mock interpreters are
expected to reject this constructor.
-}
RunTransaction :: Tx.Transaction a -> Store m a
{- | Like 'RunTransaction' but uses
'Hasql.Transaction.Sessions.transactionNoRetry' under the hood —
the body is run exactly once even on PostgreSQL serialization
conflicts.
-}
RunTransactionNoRetry :: Tx.Transaction a -> Store m a
type instance DispatchOf Store = Dynamic
-- ---------------------------------------------------------------------------
-- PostgreSQL interpreter
-- ---------------------------------------------------------------------------
-- | Interpret Store operations against PostgreSQL via hasql-pool.
runStorePool ::
(IOE :> es, Error StoreError :> es) =>
KirokuStore ->
Eff (Store : es) a ->
Eff es a
runStorePool store = interpret_ $ \case
AppendToStream (StreamName name) expected events -> do
rejectInvalidApplicationStream name
case events of
[] -> throwError (EmptyAppendBatch (StreamName name))
_ -> pure ()
events' <- liftIO $ enrichEvents (store ^. #storeSettings) events
now <- liftIO getCurrentTime
prepared <- prepareEvents events'
let params = buildAppendParams name now prepared
let runOnce =
Pool.use (store ^. #pool) $ case expected of
ExactVersion (StreamVersion v) ->
Session.statement (params, v) SQL.appendExpectedVersion
StreamExists ->
Session.statement params SQL.appendStreamExists
NoStream ->
Session.statement params SQL.appendNoStream
AnyVersion ->
Session.statement params SQL.appendAnyVersion
firstAttempt <- liftIO runOnce
result <- case firstAttempt of
-- Match hasql-transaction's retryable SQLSTATE set, but only once:
-- PostgreSQL rolls back the victim transaction and event ids were
-- prepared before the first attempt, so this retry is idempotent.
Left usageErr
| isTransientSerializationError usageErr ->
liftIO runOnce
_ ->
pure firstAttempt
case result of
Left usageErr ->
throwError (mapUsageError name expected usageErr)
Right Nothing ->
throwError (emptyResultError name expected)
Right (Just r) ->
pure r
ReadStreamForward (StreamName name) (StreamVersion startVer) limit -> do
evs <-
usePool (store ^. #pool) $
Session.statement (name, startVer, limit) SQL.readStreamForwardStmt
liftIO $ decodeEvents (store ^. #storeSettings) evs
ReadStreamBackward (StreamName name) (StreamVersion startVer) limit -> do
let cursor = if startVer == 0 then maxBound else startVer
evs <-
usePool (store ^. #pool) $
Session.statement (name, cursor, limit) SQL.readStreamBackwardStmt
liftIO $ decodeEvents (store ^. #storeSettings) evs
ReadAllForward (GlobalPosition startPos) limit -> do
evs <-
usePool (store ^. #pool) $
Session.statement (startPos, limit) SQL.readAllForwardStmt
liftIO $ decodeEvents (store ^. #storeSettings) evs
ReadAllBackward (GlobalPosition startPos) limit -> do
let cursor = if startPos == 0 then maxBound else startPos
evs <-
usePool (store ^. #pool) $
Session.statement (cursor, limit) SQL.readAllBackwardStmt
liftIO $ decodeEvents (store ^. #storeSettings) evs
GetStream (StreamName name) ->
usePool (store ^. #pool) $
Session.statement name SQL.getStreamStmt
LookupStreamId (StreamName name) ->
fmap (fmap StreamId) $
usePool (store ^. #pool) $
Session.statement name SQL.findStreamIdStmt
EventExistsInStream (StreamName name) (EventId eid) ->
usePool (store ^. #pool) $
Session.statement (name, eid) SQL.eventExistsInStreamStmt
LookupStreamNames [] ->
pure Map.empty
LookupStreamNames sids ->
fmap
(Map.fromList . map (\(s, nm) -> (StreamId s, StreamName nm)) . V.toList)
( usePool (store ^. #pool) $
Session.statement [s | StreamId s <- sids] SQL.lookupStreamNamesStmt
)
LinkToStream (StreamName name) eventIds -> do
rejectInvalidApplicationStream name
let uuids = V.fromList [uid | EventId uid <- eventIds]
result <-
liftIO $
Pool.use (store ^. #pool) $
Session.statement (uuids, name) SQL.linkToStreamStmt
case result of
Left usageErr -> throwError (mapLinkUsageError (StreamName name) usageErr)
Right Nothing -> throwError (StreamNotFound (StreamName name))
Right (Just r) -> pure r
ReadCategoryForward (CategoryName cat) (GlobalPosition startPos) limit -> do
evs <-
usePool (store ^. #pool) $
Session.statement (startPos, cat, limit) SQL.readCategoryForwardStmt
liftIO $ decodeEvents (store ^. #storeSettings) evs
AppendMultiStream [] ->
pure []
AppendMultiStream ops -> do
case firstStreamNameError ops of
Just err -> throwError err
Nothing -> pure ()
case find (\(_, _, evts) -> null evts) ops of
Just (sn, _, _) -> throwError (EmptyAppendBatch sn)
Nothing -> pure ()
now <- liftIO getCurrentTime
-- Prepare all events for all streams
preparedOps <-
mapM
( \(sn, ev, evts) -> do
evts' <- liftIO $ enrichEvents (store ^. #storeSettings) evts
prepared <- prepareEvents evts'
pure (sn, ev, prepared)
)
ops
let names = V.fromList [n | (StreamName n, _, _) <- ops]
let runOnce =
Pool.use (store ^. #pool) $
runAppendMultiStreamPipeline names now preparedOps
firstAttempt <- liftIO runOnce
result <- case firstAttempt of
-- Match the single-stream append retry and hasql-transaction's
-- retryable SQLSTATE set. The failed transaction has rolled back,
-- and event ids were prepared before the first attempt, so retrying
-- once is idempotent.
Left usageErr
| isTransientSerializationError usageErr ->
liftIO runOnce
_ ->
pure firstAttempt
case result of
Left usageErr ->
throwError (attributeMultiStreamError [(sn, ev) | (sn, ev, _) <- ops] usageErr)
Right results -> do
-- Check for any Nothing results (version conflicts)
let indexed = zip ops results
mapM
( \((StreamName sn, ev, _), mResult) ->
case mResult of
Nothing -> throwError (emptyResultError sn ev)
Just r -> pure r
)
indexed
FindEvents filt -> do
evs <- case filt of
FilterCorrelation cid ->
usePool (store ^. #pool) $
Session.statement cid SQL.findByCorrelationStmt
FilterCausationDescendants (EventId eid) ->
usePool (store ^. #pool) $
Session.statement eid SQL.findCausationDescendantsStmt
FilterCausationAncestors (EventId eid) ->
usePool (store ^. #pool) $
Session.statement eid SQL.findCausationAncestorsStmt
liftIO $ decodeEvents (store ^. #storeSettings) evs
SoftDeleteStream (StreamName name) -> do
rejectInvalidApplicationStream name
usePool (store ^. #pool) $
Session.statement name SQL.softDeleteStreamStmt
HardDeleteStream (StreamName name) -> do
rejectInvalidApplicationStream name
let txn = do
Tx.sql "SET LOCAL kiroku.enable_hard_deletes = 'on'"
mSid <- Tx.statement name SQL.findStreamIdStmt
case mSid of
Nothing -> pure Nothing
Just sid -> do
originated <- Tx.statement sid SQL.deleteAllRowsForOriginStmt
Tx.statement originated SQL.deleteJunctionsByEventIdsStmt
linkedIn <- Tx.statement sid SQL.deleteStreamOwnJunctionsStmt
let affected = originated <> linkedIn
Tx.statement affected SQL.deleteDeadLettersForOrphanedEventsStmt
Tx.statement affected SQL.deleteOrphanedEventsStmt
Tx.statement sid SQL.deleteStreamRowStmt
pure (Just (StreamId sid))
result <-
usePool (store ^. #pool) $
TxSessions.transaction TxSessions.ReadCommitted TxSessions.Write txn
-- Emit a fail-safe audit signal when the delete actually removed
-- rows. Compliance-grade audit should still record an
-- application-level event before calling hardDeleteStream — see
-- docs/PRODUCTION-DEPLOYMENT.md.
case result of
Just sid -> liftIO $ for_ (store ^. #eventHandler) ($ KirokuEventHardDeleteIssued (StreamName name) sid)
Nothing -> pure ()
pure result
UndeleteStream (StreamName name) -> do
rejectInvalidApplicationStream name
usePool (store ^. #pool) $
Session.statement name SQL.undeleteStreamStmt
SetStreamTruncateBefore (StreamName name) (StreamVersion v) -> do
rejectInvalidApplicationStream name
usePool (store ^. #pool) $
Session.statement (name, v) SQL.setStreamTruncateBeforeStmt
RunTransaction tx ->
runTxOnPool (store ^. #pool) TxSessions.transaction tx
RunTransactionNoRetry tx ->
runTxOnPool (store ^. #pool) TxSessions.transactionNoRetry tx
-- | Convenience: run a Store computation to IO.
runStoreIO ::
KirokuStore ->
Eff '[Store, Error StoreError, IOE] a ->
IO (Either StoreError a)
runStoreIO store = runEff . runErrorNoCallStack . runStorePool store
-- | Interpret Store by reading the store handle from 'KirokuStoreResource'.
runStoreResource ::
(IOE :> es, Error StoreError :> es, KirokuStoreResource :> es) =>
Eff (Store : es) a ->
Eff es a
runStoreResource action = do
store <- getKirokuStore
runStorePool store action
-- ---------------------------------------------------------------------------
-- Internal pool helper
-- ---------------------------------------------------------------------------
-- | Run a hasql session against the pool, mapping pool errors to 'StoreError'.
usePool ::
(IOE :> es, Error StoreError :> es) =>
Pool ->
Session.Session a ->
Eff es a
usePool pool session = do
result <- liftIO (Pool.use pool session)
case result of
Left usageErr -> throwError (ConnectionError (T.pack (show usageErr)))
Right a -> pure a
{- | Run a 'Tx.Transaction' against the pool using the supplied entry
point ('TxSessions.transaction' or 'TxSessions.transactionNoRetry'),
mapping pool errors to 'StoreError'. The isolation level and access
mode mirror 'appendMultiStream' / 'HardDeleteStream'.
-}
runTxOnPool ::
(IOE :> es, Error StoreError :> es) =>
Pool ->
(TxSessions.IsolationLevel -> TxSessions.Mode -> Tx.Transaction a -> Session.Session a) ->
Tx.Transaction a ->
Eff es a
runTxOnPool pool entry tx = do
result <-
liftIO $
Pool.use pool $
entry TxSessions.ReadCommitted TxSessions.Write tx
case result of
Left usageErr -> throwError (mapTransactionUsageError usageErr)
Right a -> pure a
rejectInvalidApplicationStream ::
(Error StoreError :> es) =>
Text ->
Eff es ()
rejectInvalidApplicationStream name =
either throwError pure (validateStreamName (StreamName name))
firstStreamNameError :: [(StreamName, ExpectedVersion, [EventData])] -> Maybe StoreError
firstStreamNameError ops =
case find (isLeft . validateStreamName . streamNameOf) ops of
Just (sn, _, _) -> either Just (const Nothing) (validateStreamName sn)
Nothing -> Nothing
where
streamNameOf (sn, _, _) = sn
isLeft = either (const True) (const False)
-- ---------------------------------------------------------------------------
-- Internal helpers (moved from Append)
-- ---------------------------------------------------------------------------
-- | An event with a guaranteed event ID (pre-generated if needed).
data PreparedEvent = PreparedEvent
{ peEventId :: !UUID
, peEventType :: !EventType
, pePayload :: !Value
, peMetadata :: !(Maybe Value)
, peCausationId :: !(Maybe UUID)
, peCorrelationId :: !(Maybe UUID)
}
deriving stock (Generic)
{- | Prepare events by generating UUIDv7s for any event that doesn't
have a caller-supplied event ID.
-}
prepareEvents :: (MonadIO m) => [EventData] -> m [PreparedEvent]
prepareEvents evts = liftIO $ do
let needCount = length (filter (\(EventData eid _ _ _ _ _) -> isNothing eid) evts)
newIds <-
if needCount > 0
then V7.genUUIDs (fromIntegral needCount)
else pure []
pure (assign evts newIds)
where
assign :: [EventData] -> [UUID] -> [PreparedEvent]
assign [] _ = []
assign (EventData mEid eType ePayload eMeta eCaus eCorr : es) ids =
case mEid of
Just (EventId uid) ->
PreparedEvent uid eType ePayload eMeta eCaus eCorr
: assign es ids
Nothing -> case ids of
(uid : rest) ->
PreparedEvent uid eType ePayload eMeta eCaus eCorr
: assign es rest
[] -> error "prepareEvents: ran out of pre-generated UUIDs (bug)"
-- | Build SQL parameters from prepared events.
buildAppendParams :: Text -> UTCTime -> [PreparedEvent] -> SQL.AppendParams
buildAppendParams name now prepared =
SQL.AppendParams
{ eventIds = V.fromList (map (^. #peEventId) prepared)
, eventTypes = V.fromList (map (\e -> let EventType t = e ^. #peEventType in t) prepared)
, causationIds = V.fromList (map (^. #peCausationId) prepared)
, correlationIds = V.fromList (map (^. #peCorrelationId) prepared)
, payloads = V.fromList (map (^. #pePayload) prepared)
, metadatas = V.fromList (map (^. #peMetadata) prepared)
, createdAts = V.fromList (replicate (length prepared) now)
, streamName = name
}
multiAppendBeginStmt :: Statement () ()
multiAppendBeginStmt = unpreparable "BEGIN" E.noParams D.noResult
multiAppendCommitStmt :: Statement () ()
multiAppendCommitStmt = unpreparable "COMMIT" E.noParams D.noResult
multiAppendRollbackStmt :: Statement () ()
multiAppendRollbackStmt = unpreparable "ROLLBACK" E.noParams D.noResult
runAppendMultiStreamPipeline ::
Vector Text ->
UTCTime ->
[(StreamName, ExpectedVersion, [PreparedEvent])] ->
Session.Session [Maybe AppendResult]
runAppendMultiStreamPipeline names now preparedOps =
Except.catchError body $ \err -> do
Session.statement () multiAppendRollbackStmt
Except.throwError err
where
body = do
results <-
Session.pipeline $
Pipeline.statement () multiAppendBeginStmt
-- Pre-lock the user-named streams in deterministic (stream_id)
-- order to avoid row-lock deadlocks between concurrent
-- multi-stream txns touching overlapping streams in different
-- user orders. See EP-1 F4.
*> Pipeline.statement names SQL.lockStreamsForMultiStmt
*> traverse appendPrepared preparedOps
Session.statement () $
if any isNothing results
then multiAppendRollbackStmt
else multiAppendCommitStmt
pure results
appendPrepared (StreamName name, expected, prepared) =
appendDispatchPipeline expected (buildAppendParams name now prepared)
appendDispatchPipeline :: ExpectedVersion -> SQL.AppendParams -> Pipeline.Pipeline (Maybe AppendResult)
appendDispatchPipeline expected params = case expected of
ExactVersion (StreamVersion v) ->
Pipeline.statement (params, v) SQL.appendExpectedVersion
StreamExists ->
Pipeline.statement params SQL.appendStreamExists
NoStream ->
Pipeline.statement params SQL.appendNoStream
AnyVersion ->
Pipeline.statement params SQL.appendAnyVersion
{- | Dispatch the four 'SQL.append*' statements through 'Tx.statement',
selecting the right one based on the supplied 'ExpectedVersion'.
This is the shared building block used by 'AppendMultiStream'\'s
interpreter branch and by 'Kiroku.Store.Transaction.appendToStreamTx'.
'AppendToStream' keeps its 'Session.statement'-flavored dispatch — see
the M2 entry in the Decision Log on plan 11 for why a 'Tx.Transaction'
wrapping a single statement was rejected as a refactoring target.
@'Nothing'@ comes back when the underlying CTE returns 0 rows — i.e.
the precondition failed silently. Callers map that to either
'Kiroku.Store.Error.AppendConflict' (the Tx surface) or
'Kiroku.Store.Error.StoreError' (the @Eff@ surface).
-}
appendDispatchTx :: ExpectedVersion -> SQL.AppendParams -> Tx.Transaction (Maybe AppendResult)
appendDispatchTx expected params = case expected of
ExactVersion (StreamVersion v) ->
Tx.statement (params, v) SQL.appendExpectedVersion
StreamExists ->
Tx.statement params SQL.appendStreamExists
NoStream ->
Tx.statement params SQL.appendNoStream
AnyVersion ->
Tx.statement params SQL.appendAnyVersion
{- $internal
These bindings are intentionally exposed so that
"Kiroku.Store.Transaction" can compose appends with arbitrary
'Tx.Transaction' work without re-implementing UUID prep, parameter
packing, or per-version dispatch. They are not part of the supported
public surface and may change without notice.
-}