kiroku-store-0.13.0.0: src/Kiroku/Store/Compaction/Internal.hs
{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE NoFieldSelectors #-}
module Kiroku.Store.Compaction.Internal
( ValidatedCompaction (..),
ValidationOutcome (..),
LedgerOutcome (..),
ledgerOutcomeTx,
validateCompactionTx,
buildReport,
previewCompactionTx,
applyCompactionTx,
applyCompactionTxWith,
compactionLedgerTx,
)
where
import Control.Monad (unless, void)
import Data.Int (Int64)
import Data.List (find)
import Data.List.NonEmpty (NonEmpty (..))
import Data.List.NonEmpty qualified as NE
import Data.Map.Strict qualified as Map
import Data.Set qualified as Set
import Data.Text.Encoding qualified as TE
import Data.UUID (UUID)
import Data.Vector (Vector)
import Data.Vector qualified as V
import GHC.Generics (Generic)
import Hasql.Transaction qualified as Tx
import Kiroku.Store.Compaction.SQL qualified as SQL
import Kiroku.Store.Compaction.Types
import Kiroku.Store.HistoryRetention.Internal qualified as Retention
import Kiroku.Store.SQL qualified as StoreSQL
import Kiroku.Store.Types
data ValidatedCompaction = ValidatedCompaction
{ report :: !CompactionReport,
selectedEventIds :: !(Vector UUID),
junctionRows :: !(Vector (UUID, Int64))
}
deriving stock (Eq, Show, Generic)
data ValidationOutcome
= ValidationRefused (NonEmpty CompactionRefusal)
| ValidationAlreadyApplied CompactionRecord
| ValidationReady ValidatedCompaction
deriving stock (Eq, Show)
data LedgerOutcome = LedgerUnrecorded | LedgerAlreadyApplied CompactionRecord | LedgerConflict CompactionId Int64
deriving stock (Eq, Show)
selectedIds :: CompactionManifest -> Vector UUID
selectedIds = V.map (\s -> let EventId uid = s.eventId in uid) . manifestSelections
ledgerOutcomeTx :: CompactionManifest -> Tx.Transaction LedgerOutcome
ledgerOutcomeTx manifest = do
found <- Tx.statement (compactionDigestBytes (compactionManifestDigest manifest)) SQL.ledgerRecordByDigestStmt
case found of
Nothing -> pure LedgerUnrecorded
Just record -> do
survivors <- Tx.statement (selectedIds manifest) SQL.survivingSelectedEventsStmt
pure (if survivors == 0 then LedgerAlreadyApplied record else LedgerConflict record.compactionId survivors)
-- No import of Store.Transaction: it imports Effect, which consumes this module.
validateCompactionTx :: CompactionManifest -> Tx.Transaction ValidationOutcome
validateCompactionTx manifest = do
identity <- Tx.statement () StoreSQL.storeIdentityStmt
if identity /= manifestStoreIdentity manifest
then pure (ValidationRefused (CompactionStoreIdentityMismatch (manifestStoreIdentity manifest) identity :| []))
else do
conflict <- Retention.activeHistoryRetentionConflictTx
let leaseRefusals = maybe [] (pure . CompactionHistoryRetentionActive) conflict
ledger <- ledgerOutcomeTx manifest
case ledger of
LedgerAlreadyApplied record -> pure $ case NE.nonEmpty leaseRefusals of
Just refusals -> ValidationRefused refusals
Nothing -> ValidationAlreadyApplied record
LedgerConflict cid count -> pure (ValidationRefused (CompactionLedgerConflict cid count :| []))
LedgerUnrecorded -> validateWitnessesTx manifest leaseRefusals
validateWitnessesTx :: CompactionManifest -> [CompactionRefusal] -> Tx.Transaction ValidationOutcome
validateWitnessesTx manifest leaseRefusals = do
streams <- Tx.statement (manifestStreamNames manifest) SQL.resolveStreamsStmt
let resolved = Map.fromList [(StreamName name, (sid, ver, deleted)) | (name, sid, ver, deleted) <- V.toList streams]
allSelections = V.toList (manifestSelections manifest)
resolvable = filter (\s -> Map.member s.originStream resolved) allSelections
ids = V.fromList [uid | s <- resolvable, let EventId uid = s.eventId]
streamRefusals = concatMap (checkStream resolved) (V.toList (manifestStreamHeads manifest))
rows <- if V.null ids then pure V.empty else Tx.statement ids SQL.eventMembershipsStmt
let grouped = Map.map reverse (Map.fromListWith (<>) [(EventId uid, [(sid, StreamName name, ver, origin, originVer)]) | (uid, sid, name, ver, origin, originVer) <- V.toList rows])
knownNames =
Map.fromList [(sid, StreamName name) | (name, sid, _, _) <- V.toList streams]
<> Map.fromList [(sid, StreamName name) | (_, sid, name, _, _, _) <- V.toList rows]
missingOriginIds = Set.toList (Set.fromList [origin | (_, _, _, _, origin, _) <- V.toList rows, Map.notMember origin knownNames])
extraNames <- if null missingOriginIds then pure V.empty else Tx.statement missingOriginIds StoreSQL.lookupStreamNamesStmt
-- The causation exclusion must cover the whole manifest, even when another
-- selected event has an invalid witness. Report policies only for verified rows.
deadLetters <- Tx.statement (selectedIds manifest) SQL.deadLetterCountsStmt
dependents <- Tx.statement (selectedIds manifest) SQL.causationDependentCountsStmt
let names = knownNames <> Map.fromList [(sid, StreamName name) | (sid, name) <- V.toList extraNames]
deadMap = Map.fromList (V.toList deadLetters)
dependentMap = Map.fromList (V.toList dependents)
results = map (checkSelection resolved grouped names deadMap dependentMap) resolvable
refusals = leaseRefusals <> streamRefusals <> concatMap fst results
pairs = concatMap snd results
links = sum [fromIntegral (V.length s.acknowledgedLinks) | s <- allSelections]
deadCount = if manifestDeadLetterPolicy manifest == RemoveDeadLetters then sum (Map.elems deadMap) else 0
causeCount = if manifestCausationPolicy manifest == AllowDanglingCausation then sum (Map.elems dependentMap) else 0
pure $ case NE.nonEmpty refusals of
Just found -> ValidationRefused found
Nothing -> ValidationReady (ValidatedCompaction (buildReport manifest links deadCount causeCount) (selectedIds manifest) (V.fromList pairs))
where
checkStream resolved h = case Map.lookup h.stream resolved of
Nothing -> [CompactionStreamMissing h.stream]
Just (_, ver, deleted) ->
[CompactionStreamSoftDeleted h.stream | deleted]
<> [CompactionStreamHeadDrift h.stream h.headVersion (StreamVersion ver) | StreamVersion ver /= h.headVersion]
checkSelection resolved grouped names deadMap dependentMap s =
case Map.findWithDefault [] s.eventId grouped of
[] -> ([CompactionSelectedEventMissing s.eventId], [])
liveRows@((_, _, _, actualOrigin, actualVersion) : _) ->
let EventId uid = s.eventId
originId = maybe (-1) (\(sid, _, _) -> sid) (Map.lookup s.originStream resolved)
actualName = Map.findWithDefault s.originStream actualOrigin names
global = fmap (\(_, _, v, _, _) -> GlobalPosition v) (find (\(sid, _, _, _, _) -> sid == 0) liveRows)
home = find (\(sid, _, _, _, _) -> sid == originId) liveRows
matchesHome = case home of
Just (_, _, ver, origin, originVer) -> origin == originId && StreamVersion ver == s.originVersion && StreamVersion originVer == s.originVersion
Nothing -> False
originsMatch = all (\(_, _, _, origin, ver) -> origin == originId && StreamVersion ver == s.originVersion) liveRows
witnesses =
[ CompactionWitnessMismatch s.eventId (WitnessMismatch actualName (StreamVersion actualVersion) global)
| not (matchesHome && originsMatch && global == Just s.globalPosition)
]
actualLinks = [(LinkWitness name (StreamVersion ver), sid) | (sid, name, ver, origin, _) <- liveRows, sid /= 0, sid /= origin]
expectedLinks = V.toList s.acknowledgedLinks
key (LinkWitness name ver) = (name, ver)
actualKeys = Set.fromList (map (key . fst) actualLinks)
expectedKeys = Set.fromList (map key expectedLinks)
linkRefusals =
[CompactionUnexpectedLink s.eventId link | (link, _) <- actualLinks, Set.notMember (key link) expectedKeys]
<> [CompactionAcknowledgedLinkMissing s.eventId link | link <- expectedLinks, Map.member link.stream resolved, Set.notMember (key link) actualKeys]
verified = null witnesses && null linkRefusals
dead = Map.findWithDefault 0 uid deadMap
cause = Map.findWithDefault 0 uid dependentMap
referenceRefusals =
if not verified
then []
else
[CompactionDeadLettersPresent s.eventId dead | dead > 0, manifestDeadLetterPolicy manifest == RefuseDeadLetters]
<> [CompactionCausationDependentsPresent s.eventId cause | cause > 0, manifestCausationPolicy manifest == RefuseCausationDependents]
pairs = if verified then (uid, originId) : (uid, 0) : [(uid, sid) | (_, sid) <- actualLinks] else []
in (witnesses <> linkRefusals <> referenceRefusals, pairs)
buildReport :: CompactionManifest -> Int64 -> Int64 -> Int64 -> CompactionReport
buildReport manifest links dead cause =
sealCompactionReport $
CompactionReport
digest
(manifestStoreIdentity manifest)
(manifestOperation manifest)
(manifestDeadLetterPolicy manifest)
(manifestCausationPolicy manifest)
count
count
count
links
dead
cause
(V.head selections).globalPosition
(V.last selections).globalPosition
(manifestStreamHeads manifest)
digest
where
selections = manifestSelections manifest
count = fromIntegral (V.length selections)
digest = compactionManifestDigest manifest
previewCompactionTx :: CompactionManifest -> Tx.Transaction (Either (NonEmpty CompactionRefusal) CompactionPreview)
previewCompactionTx manifest =
validateCompactionTx manifest >>= \case
ValidationRefused refusals -> pure (Left refusals)
ValidationAlreadyApplied record -> pure (Right (CompactionPreviewAlreadyApplied record))
ValidationReady validated -> pure (Right (CompactionPreviewReady validated.report))
applyCompactionTx :: CompactionManifest -> Tx.Transaction (Either (NonEmpty CompactionRefusal) CompactionApplyResult)
applyCompactionTx = applyCompactionTxWith (pure ())
applyCompactionTxWith :: Tx.Transaction () -> CompactionManifest -> Tx.Transaction (Either (NonEmpty CompactionRefusal) CompactionApplyResult)
applyCompactionTxWith barrier manifest = do
identity <- Tx.statement () StoreSQL.storeIdentityStmt
if identity /= manifestStoreIdentity manifest
then pure (Left (CompactionStoreIdentityMismatch (manifestStoreIdentity manifest) identity :| []))
else do
Tx.sql "SET LOCAL kiroku.enable_hard_deletes = 'on'"
Retention.lockHistoryRetentionCoordinatorTx
conflict <- Retention.activeHistoryRetentionConflictTx
case conflict of
Just active -> pure (Left (CompactionHistoryRetentionActive active :| []))
Nothing ->
ledgerOutcomeTx manifest >>= \case
LedgerAlreadyApplied record -> pure (Right (CompactionAlreadyApplied record))
LedgerConflict cid count -> pure (Left (CompactionLedgerConflict cid count :| []))
LedgerUnrecorded -> do
void (Tx.statement (manifestStreamNames manifest) SQL.lockCompactionStreamsStmt)
validateCompactionTx manifest >>= \case
ValidationRefused refusals -> pure (Left refusals)
ValidationAlreadyApplied record -> pure (Right (CompactionAlreadyApplied record))
ValidationReady validated -> do
barrier
Right . CompactionAppliedNow <$> deleteAndRecord validated
where
deleteAndRecord validated = do
let r = validated.report
(eventKeys, streamKeys) = V.unzip validated.junctionRows
removed <- Tx.statement (eventKeys, streamKeys) SQL.deleteJunctionRowsStmt
guardCount "junction count" (fromIntegral (V.length validated.junctionRows)) (fromIntegral (V.length removed))
let home = V.length (V.filter (\(sid, origin) -> sid /= 0 && sid == origin) removed)
global = V.length (V.filter (\(sid, _) -> sid == 0) removed)
links = V.length removed - home - global
guardCount "home membership count" r.homeMemberships (fromIntegral home)
guardCount "global membership count" r.globalMemberships (fromIntegral global)
guardCount "link membership count" r.linkMemberships (fromIntegral links)
if manifestDeadLetterPolicy manifest == RemoveDeadLetters
then Tx.statement validated.selectedEventIds SQL.deleteSelectedDeadLettersStmt >>= guardCount "dead-letter count" r.deadLettersRemoved
else pure ()
Tx.statement validated.selectedEventIds SQL.deleteSelectedEventsStmt >>= guardCount "events count" r.selectedEvents
let inserted =
SQL.CompactionRecordInsert
r.manifestDigest
r.reportDigest
r.storeIdentity
r.operation
r.deadLetterPolicy
r.causationPolicy
r.selectedEvents
r.homeMemberships
r.globalMemberships
r.linkMemberships
r.deadLettersRemoved
r.causationDependents
r.lowestGlobalPosition
r.highestGlobalPosition
r.affectedStreams
(cid, applied, actor) <- Tx.statement inserted SQL.insertCompactionRecordStmt
pure (CompactionRecord (CompactionId cid) r applied actor)
-- Only fixed guard labels and a validated lowercase hex digest enter SQL.
-- PostgreSQL aborts the transaction itself; returning Left here would commit
-- earlier deletes in a composer that kept running after the result.
guardCount label expected actual =
unless (expected == actual) $
Tx.sql
( TE.encodeUtf8
( "DO $$ BEGIN RAISE EXCEPTION USING ERRCODE='KRCMP', MESSAGE='"
<> label
<> "', DETAIL='"
<> compactionDigestHex (compactionManifestDigest manifest)
<> "'; END $$"
)
)
compactionLedgerTx :: CompactionLedgerQuery -> Tx.Transaction (Vector CompactionRecord)
compactionLedgerTx query = Tx.statement (compactionLedgerLimitValue query.limit) SQL.compactionLedgerStmt