kiroku-store-0.12.0.0: src/Kiroku/Store/HistoryRetention.hs
-- | Durable replay-history retention and transaction-scoped stream guards.
--
-- A lease protects the retained global event set from Kiroku hard delete and
-- authorized direct SQL deletion until its database-derived expiry. Transaction
-- combinators persist durable evidence but cannot emit process-local observability
-- events from inside an opaque caller-owned transaction.
--
-- Lease duration is one second through one hour. Expiry is passive: a crashed
-- owner blocks destructive work only until PostgreSQL time reaches @expiresAt@.
-- The owner is an accidental-mutation guard, not an authorization credential;
-- database roles and grants remain the security boundary. Direct destructive SQL
-- receives SQLSTATE @KR001@ while any lease is active.
--
-- When one transaction needs both a lease operation and a stream-history guard,
-- perform the lease operation first. This preserves Kiroku's coordinator-before-
-- stream-row lock order.
module Kiroku.Store.HistoryRetention
( module Kiroku.Store.HistoryRetention.Types,
acquireHistoryRetentionLeaseTx,
renewHistoryRetentionLeaseTx,
releaseHistoryRetentionLeaseTx,
historyRetentionLeaseInventoryTx,
pruneHistoryRetentionLeasesTx,
acquireHistoryRetentionLease,
renewHistoryRetentionLease,
releaseHistoryRetentionLease,
historyRetentionLeaseInventory,
pruneHistoryRetentionLeases,
lockStreamHistoryForReplayTx,
readStreamForwardTx,
)
where
import Data.Int (Int32)
import Data.Time.Clock (UTCTime)
import Data.Vector (Vector)
import Effectful (Eff, (:>))
import Effectful.Dispatch.Dynamic (send)
import Hasql.Transaction qualified as Tx
import Kiroku.Store.Effect (Store (..))
import Kiroku.Store.HistoryRetention.Internal qualified as Internal
import Kiroku.Store.HistoryRetention.SQL qualified as HistoryRetentionSQL
import Kiroku.Store.HistoryRetention.Types
import Kiroku.Store.SQL qualified as SQL
import Kiroku.Store.Types (RecordedEvent, StreamInfo, StreamName (..), StreamVersion (..))
-- | Acquire a new lease and atomically capture the authoritative @$all@
-- frontier as the inclusive @protectedThrough@ rebuild ceiling. PostgreSQL
-- supplies every timestamp. Rollback leaves no lease row.
acquireHistoryRetentionLeaseTx :: HistoryRetentionLeaseRequest -> Tx.Transaction HistoryRetentionLease
acquireHistoryRetentionLeaseTx = Internal.acquireHistoryRetentionLeaseTx
-- | Renew a still-active lease without shortening its current expiry.
-- Unknown, wrong-owner, expired, and released leases return distinct typed
-- errors and are never resurrected.
renewHistoryRetentionLeaseTx ::
HistoryRetentionLeaseHandle ->
HistoryRetentionLeaseDuration ->
Tx.Transaction (Either HistoryRetentionRenewalError HistoryRetentionLease)
renewHistoryRetentionLeaseTx = Internal.renewHistoryRetentionLeaseTx
-- | Release a live lease. Repetition is typed and does not rewrite the row.
releaseHistoryRetentionLeaseTx ::
HistoryRetentionLeaseHandle ->
Tx.Transaction HistoryRetentionReleaseResult
releaseHistoryRetentionLeaseTx = Internal.releaseHistoryRetentionLeaseTx
-- | Read a deterministic, database-time-derived, bounded inventory. Active,
-- expired, and released state is derived at statement time; no expiry worker is
-- required.
historyRetentionLeaseInventoryTx ::
HistoryRetentionInventoryQuery ->
Tx.Transaction (Vector HistoryRetentionLease)
historyRetentionLeaseInventoryTx = Internal.historyRetentionLeaseInventoryTx
-- | Remove only expired or released rows strictly older than the supplied cutoff.
pruneHistoryRetentionLeasesTx :: UTCTime -> Tx.Transaction HistoryRetentionPruneResult
pruneHistoryRetentionLeasesTx = Internal.pruneHistoryRetentionLeasesTx
-- | Acquire a lease through the mockable 'Store' effect. The production
-- interpreter emits the committed acquisition event after the transaction ends.
acquireHistoryRetentionLease :: (Store :> es) => HistoryRetentionLeaseRequest -> Eff es HistoryRetentionLease
acquireHistoryRetentionLease request = send (AcquireHistoryRetentionLease request)
-- | Renew a lease through the mockable 'Store' effect and emit only a committed success.
renewHistoryRetentionLease ::
(Store :> es) =>
HistoryRetentionLeaseHandle ->
HistoryRetentionLeaseDuration ->
Eff es (Either HistoryRetentionRenewalError HistoryRetentionLease)
renewHistoryRetentionLease handle duration = send (RenewHistoryRetentionLease handle duration)
-- | Release a lease through the mockable 'Store' effect. Repeated release emits no false transition.
releaseHistoryRetentionLease ::
(Store :> es) =>
HistoryRetentionLeaseHandle ->
Eff es HistoryRetentionReleaseResult
releaseHistoryRetentionLease handle = send (ReleaseHistoryRetentionLease handle)
-- | Read the bounded durable inventory through the mockable 'Store' effect.
historyRetentionLeaseInventory ::
(Store :> es) =>
HistoryRetentionInventoryQuery ->
Eff es (Vector HistoryRetentionLease)
historyRetentionLeaseInventory query = send (GetHistoryRetentionLeaseInventory query)
-- | Prune terminal rows through the mockable 'Store' effect.
pruneHistoryRetentionLeases ::
(Store :> es) =>
UTCTime ->
Eff es HistoryRetentionPruneResult
pruneHistoryRetentionLeases cutoff = send (PruneHistoryRetentionLeases cutoff)
-- | Lock one application's stream row in share mode until the surrounding
-- transaction ends. The returned metadata includes soft-delete and logical
-- truncate state so a repair can reject incomplete history before mutating its
-- target. @$all@ is reserved.
--
-- When composing this guard with a lease operation, acquire or renew the lease
-- first and take this guard second.
lockStreamHistoryForReplayTx ::
StreamName ->
Tx.Transaction (Either StreamHistoryUnavailable StreamInfo)
lockStreamHistoryForReplayTx stream@(StreamName name)
| name == "$all" = pure (Left (StreamHistoryReserved stream))
| otherwise = do
locked <- Tx.statement name HistoryRetentionSQL.lockStreamHistoryStmt
pure $ maybe (Left (StreamHistoryNotFound stream)) Right locked
-- | Read a page inside the caller's transaction using the production
-- exclusive-lower cursor and ascending-order statement. This function deliberately
-- does not run 'Kiroku.Store.Settings.decodeHook': 'Tx.Transaction' has no 'IO'.
-- The caller must take 'lockStreamHistoryForReplayTx' earlier in the same
-- transaction when stable one-stream history is required.
readStreamForwardTx ::
StreamName ->
StreamVersion ->
Int32 ->
Tx.Transaction (Vector RecordedEvent)
readStreamForwardTx (StreamName name) (StreamVersion cursor) limit =
Tx.statement (name, cursor, limit) SQL.readStreamForwardStmt