packages feed

bolty-0.2.0.0: src/Database/Bolty/Session.hs

-- | Sessions: managed transactions with automatic bookmark tracking for causal consistency.
module Database.Bolty.Session
  ( -- * Bookmark management
    BookmarkManager
  , newBookmarkManager
  , getBookmarks
  , updateBookmark
    -- * Session
  , Session(..)
  , SessionPool(..)
  , SessionConfig(..)
  , defaultSessionConfig
  , createSession
  , createRoutingSession
    -- * Managed transactions
  , readTransaction
  , writeTransaction
    -- * Querying
  , getLastBookmarks
  ) where

import           Control.Concurrent             (threadDelay)
import           Control.Exception              (SomeException, fromException, onException,
                                                 throwIO, try)
import           Control.Monad                  (when)
import qualified Data.HashMap.Lazy              as H
import           Data.IORef                     (IORef, newIORef, readIORef, writeIORef)
import           Data.Kind                      (Type)
import           Data.Text                      (Text)
import qualified Data.Vector                    as V
import           GHC.Stack                      (HasCallStack)

import           Database.Bolty.AccessMode      (AccessMode(..))
import           Database.Bolty.Connection.Type
import qualified Database.Bolty.Connection.Pipe as P
import           Database.Bolty.Message.Request (Begin(Begin), TelemetryApi(..))
import           Database.Bolty.Pool
import           Database.Bolty.Routing (RoutingPool(..), withRoutingConnection,
                                        invalidateRoutingTable)


-- ---------------------------------------------------------------------------
-- Bookmark management
-- ---------------------------------------------------------------------------

-- | Mutable bookmark holder for causal consistency across transactions.
-- Within a session, each committed transaction produces a bookmark that
-- supersedes all previous bookmarks. The manager tracks the latest
-- bookmark(s) so they can be passed to the next transaction's BEGIN.
type BookmarkManager :: Type
newtype BookmarkManager = BookmarkManager (IORef [Text])

-- | Create a bookmark manager with optional initial bookmarks.
newBookmarkManager :: [Text] -> IO BookmarkManager
newBookmarkManager initial = BookmarkManager <$> newIORef initial

-- | Get the current bookmarks.
getBookmarks :: BookmarkManager -> IO [Text]
getBookmarks (BookmarkManager ref) = readIORef ref

-- | Replace the current bookmarks with the new one from a COMMIT response.
updateBookmark :: BookmarkManager -> Text -> IO ()
updateBookmark (BookmarkManager ref) bm = writeIORef ref [bm]


-- ---------------------------------------------------------------------------
-- Session
-- ---------------------------------------------------------------------------

-- | Session configuration.
type SessionConfig :: Type
data SessionConfig = SessionConfig
  { database         :: !(Maybe Text)
  -- ^ Database to use (Nothing = default database).
  , accessMode       :: !AccessMode
  -- ^ Default access mode for auto-commit queries.
  , sessionBookmarks :: ![Text]
  -- ^ Initial bookmarks for causal consistency (e.g. from a previous session).
  }

-- | Default session configuration: default database, write access, no initial bookmarks.
defaultSessionConfig :: SessionConfig
defaultSessionConfig = SessionConfig
  { database         = Nothing
  , accessMode       = WriteAccess
  , sessionBookmarks = []
  }


-- | Which pool type the session uses (internal).
type SessionPool :: Type
data SessionPool
  = DirectPool !BoltPool
  | RoutedPool !RoutingPool

-- | A session bundles a connection pool with bookmark management and configuration.
-- Sessions track bookmarks automatically across managed transactions
-- ('readTransaction', 'writeTransaction') to ensure causal consistency.
type Session :: Type
data Session = Session
  { sPool            :: !SessionPool
  , sBookmarks       :: !BookmarkManager
  , sConfig          :: !SessionConfig
  , sRetry           :: !RetryConfig
  , sTelemetrySent   :: !(IORef Bool)
  }


-- | Create a session using a direct (non-routing) connection pool.
createSession :: BoltPool -> SessionConfig -> IO Session
createSession pool cfg = do
  bm <- newBookmarkManager (sessionBookmarks cfg)
  ts <- newIORef False
  pure Session
    { sPool            = DirectPool pool
    , sBookmarks       = bm
    , sConfig          = cfg
    , sRetry           = bpRetryConfig pool
    , sTelemetrySent   = ts
    }


-- | Create a session using a routing-aware connection pool.
createRoutingSession :: RoutingPool -> SessionConfig -> IO Session
createRoutingSession rp cfg = do
  bm <- newBookmarkManager (sessionBookmarks cfg)
  ts <- newIORef False
  pure Session
    { sPool            = RoutedPool rp
    , sBookmarks       = bm
    , sConfig          = cfg
    , sRetry           = retryConfig (rpPoolConfig rp)
    , sTelemetrySent   = ts
    }


-- | Get the last bookmarks from the session. Pass these to a new session's
-- 'SessionConfig' to ensure the new session sees all committed writes.
getLastBookmarks :: Session -> IO [Text]
getLastBookmarks Session{sBookmarks} = getBookmarks sBookmarks


-- ---------------------------------------------------------------------------
-- Managed transactions
-- ---------------------------------------------------------------------------

-- | Run a read transaction. Uses 'ReadAccess' for routing (directs to
-- read replicas in a cluster). Automatically handles BEGIN, COMMIT,
-- ROLLBACK, bookmark propagation, and retries on transient failures.
readTransaction :: HasCallStack => Session -> (Connection -> IO a) -> IO a
readTransaction session action = managedTransaction session ReadAccess action


-- | Run a write transaction. Uses 'WriteAccess' for routing (directs to
-- the leader in a cluster). Automatically handles BEGIN, COMMIT,
-- ROLLBACK, bookmark propagation, and retries on transient failures.
writeTransaction :: HasCallStack => Session -> (Connection -> IO a) -> IO a
writeTransaction session action = managedTransaction session WriteAccess action


-- | Internal: run a managed transaction with the given access mode.
-- Re-acquires a connection on each retry so that routing errors (NotALeader)
-- result in fresh routing table lookups.
managedTransaction :: HasCallStack => Session -> AccessMode -> (Connection -> IO a) -> IO a
managedTransaction Session{sPool, sBookmarks, sConfig, sRetry, sTelemetrySent} mode action =
  let maxR = maxRetries sRetry
      initD = initialDelay sRetry
      maxD = maxDelay sRetry
  in go maxR initD maxD
  where
    go 0 _ _    = attempt
    go n delay maxD = do
      result <- try attempt
      case result of
        Right x -> pure x
        Left (e :: SomeException) -> case fromException e :: Maybe Error of
          Just err
            | isTransient err -> do
                threadDelay delay
                go (n - 1) (min maxD (delay * 2)) maxD
            | isRoutingError err -> do
                invalidatePool sPool
                threadDelay delay
                go (n - 1) (min maxD (delay * 2)) maxD
          _ -> throwIO e

    attempt =
      withSessionConnection sPool mode $ \conn -> do
        -- Get current bookmarks and begin the transaction
        bms <- getBookmarks sBookmarks
        P.beginTx conn $ Begin (V.fromList bms) Nothing H.empty mode (database sConfig) Nothing
        -- Run the user's action, rollback on error
        result <- action conn `onException` P.tryRollback conn
        -- Commit and extract bookmark
        mbBookmark <- P.commitTx conn
        case mbBookmark of
          Just bm -> updateBookmark sBookmarks bm
          Nothing -> pure ()
        -- Send telemetry after first successful managed transaction
        sent <- readIORef sTelemetrySent
        when (not sent) $ do
          writeIORef sTelemetrySent True
          P.sendTelemetry conn ManagedTransactions
        pure result

    invalidatePool (RoutedPool rp) = invalidateRoutingTable rp
    invalidatePool (DirectPool _)  = pure ()


-- | Acquire a connection based on pool type and access mode.
withSessionConnection :: SessionPool -> AccessMode -> (Connection -> IO a) -> IO a
withSessionConnection (DirectPool pool) _mode action =
  withConnection pool action
withSessionConnection (RoutedPool rp) mode action =
  withRoutingConnection rp mode action