bolty-0.1.0.0: src/Database/Bolty/Connection/Pipe.hs
-- | Internal module. Not part of the public API.
module Database.Bolty.Connection.Pipe
( connect
, close
, reset
, ping
, flush
, fetch
, sendRequest
, receiveResponse
, requireState
, setState
, getState
, logon
, logoff
, sendTelemetry
, MonadPipe
-- * Connection accessors
, connectionVersion
, connectionAgent
, connectionId
, connectionTelemetryEnabled
, connectionServerIdleTimeout
, touchConnection
, connectionLastActivity
-- * Transaction primitives (plain IO)
, beginTx
, commitTx
, rollbackTx
, tryRollback
-- * Plain IO wire helpers
, requireStateIO
, flushIO
, fetchIO
) where
import Data.Kind (Constraint, Type)
import Control.Exception (Exception, SomeException, catch, throwIO, try)
import Control.Monad (when, unless)
import Control.Monad.Trans (MonadIO(..))
import Control.Monad.Extra (whenMaybe, whileJustM)
import Control.Monad.Except
import Data.Persist (putBE, runPutLazy)
import qualified Data.Persist as P
import Data.IORef (newIORef, readIORef, writeIORef)
import GHC.Clock (getMonotonicTimeNSec)
import Data.Bits (shiftR, (.&.))
import Data.Int (Int64)
import Data.Word
import Data.PackStream.Integer (fromPSInteger)
import GHC.Stack (HasCallStack)
import Prelude
import qualified Data.ByteString as BS
import qualified Data.ByteString.Lazy as BSL
import qualified Data.HashMap.Lazy as H
import qualified Data.Text as T
import qualified Data.PackStream as PS
import Data.PackStream.Ps (Ps(..))
import qualified Data.Vector as V
import qualified Network.Connection as NC
import Database.Bolty.Connection.Type
import qualified Database.Bolty.Connection.Connection as C
import Database.Bolty.Message.Response
import Database.Bolty.Message.Request
import Database.Bolty.Value.Helpers (isNewVersion, versionMajor, supportsLogonLogoff, supportsTelemetry)
import Database.Bolty.Util
-- | Constraint alias for monads that can perform BOLT pipe operations.
type MonadPipe :: (Type -> Type) -> Constraint
type MonadPipe m = (HasCallStack, MonadIO m, MonadError Error m)
exceptToThrow :: (Exception e, MonadIO m) => ExceptT e m b -> m b
exceptToThrow f = runExceptT f >>= \case
Right y -> pure y
Left e -> liftIO $ throwIO e
-- | Connect to a Neo4j server, perform the BOLT handshake and authentication.
connect :: (MonadIO m, HasCallStack) => ValidatedConfig -> m Connection
connect ValidatedConfig{..} = do
(rawConn, tout) <- C.connect use_tls host port timeout
(server_version, agent, conn_id, telem, idleTimeout) <- exceptToThrow $ handshake rawConn tout ValidatedConfig{..}
stateRef <- liftIO $ newIORef Ready
now <- liftIO getMonotonicTimeNSec
actRef <- liftIO $ newIORef now
pure $ Connection { rawConnection = rawConn, timeout_milliseconds = tout
, version = server_version, server_state = stateRef
, server_agent = agent, connection_id = conn_id, lastActivity = actRef
, telemetry_enabled = telem, serverIdleTimeout = idleTimeout
, queryLogger = queryLogger
, notificationHandler = notificationHandler }
-- | Send GOODBYE (if supported) and close the connection.
close :: (MonadIO m, HasCallStack) => Connection -> m ()
close Connection{rawConnection, timeout_milliseconds, version, server_state} = exceptToThrow $ do
_ <- whenMaybe (isNewVersion version) $ sendRequest rawConnection timeout_milliseconds RGoodbye
liftIO $ writeIORef server_state Disconnected
C.close rawConnection timeout_milliseconds
-- | Send RESET and transition the connection back to the Ready state.
reset :: (MonadIO m, HasCallStack) => Connection -> m ()
reset Connection{rawConnection, timeout_milliseconds, server_state} = exceptToThrow $ do
sendRequest rawConnection timeout_milliseconds RReset
response <- receiveResponse rawConnection timeout_milliseconds
case response of
(RSuccess _) -> liftIO $ writeIORef server_state Ready
_ -> throwError ResetFailed
-- | Check if a Pipe is alive by sending RESET and expecting SUCCESS.
-- Returns True if healthy, False otherwise. Catches all exceptions.
ping :: MonadIO m => Connection -> m Bool
ping Connection{rawConnection, timeout_milliseconds, server_state} = liftIO $ do
result <- try @SomeException $ runExceptT $ do
sendRequest rawConnection timeout_milliseconds RReset
response <- receiveResponse rawConnection timeout_milliseconds
case response of
(RSuccess _) -> liftIO $ writeIORef server_state Ready
_ -> throwError ResetFailed
pure $ case result of
Right (Right _) -> True
_ -> False
-- | Check that the connection is in one of the allowed states, or throw 'InvalidState'
requireState :: MonadPipe m => Connection -> [ServerState] -> T.Text -> m ()
requireState Connection{server_state} allowed action = do
st <- liftIO $ readIORef server_state
unless (st `elem` allowed) $
throwError $ InvalidState st action
-- | Update the server state
setState :: MonadIO m => Connection -> ServerState -> m ()
setState Connection{server_state} st = liftIO $ writeIORef server_state st
-- | Get the current server state
getState :: MonadIO m => Connection -> m ServerState
getState Connection{server_state} = liftIO $ readIORef server_state
handshake :: MonadPipe m => NC.Connection -> Int -> ValidatedConfig -> m (Word32, T.Text, T.Text, Bool, Maybe Int)
handshake rawConn tout ValidatedConfig{versions, user_agent, scheme = authScheme, routing} = do
-- https://neo4j.com/docs/bolt/current/bolt/handshake/#_bolt_identification
C.send rawConn tout (encodeStrict (0x6060B017 :: Word32))
C.send rawConn tout $ P.runPut $ mapM_ putBE versions
server_version :: Word32 <- C.receiveBinary rawConn tout 4
when (not $ versionAccepted server_version versions) $ throwError $ UnsupportedServerVersion server_version
if supportsLogonLogoff server_version then do
-- Bolt 5.1+: HELLO without credentials (no scheme/principal/credentials fields)
let serverMinor = fromIntegral ((server_version `shiftR` 8) .&. 0xFF) :: Word8
let helloDict = PsDictionary $ H.fromList $
[ ("user_agent", PS.toPs user_agent)
] <>
-- Bolt 5.3+ requires bolt_agent dictionary
( if serverMinor >= 3 then
[("bolt_agent", PsDictionary $ H.singleton "product" (PS.toPs user_agent))]
else
[]
) <>
( case routing of
NoRouting -> []
Routing -> [("routing", PsDictionary H.empty)]
RoutingSpec address urlQueryParams -> [("routing", PsDictionary $ H.fromList $
("address", PS.toPs address) : [(k, PS.toPs v) | (k, v) <- H.toList urlQueryParams])]
)
sendPs rawConn tout $ PsStructure 0x01 $ V.singleton helloDict
helloResponse <- receiveResponse rawConn tout
case helloResponse of
RSuccess meta -> do
let agent = psToText $ H.lookupDefault (PsString "") "server" meta
let conn_id = psToText $ H.lookupDefault (PsString "") "connection_id" meta
let telem = parseTelemetryHint meta
let idleTO = parseIdleTimeoutHint meta
-- Now send LOGON with credentials
sendRequest rawConn tout $ RLogon $ Logon authScheme
logonResponse <- receiveResponse rawConn tout
case logonResponse of
RSuccess _ -> pure (server_version, agent, conn_id, telem, idleTO)
_ -> throwError AuthentificationFailed
_ -> throwError AuthentificationFailed
else do
-- Bolt 5.0 and below: HELLO with credentials
-- BOLT 4.x: include patch_bolt to get UTC-based DateTime (0x49/0x69) instead of legacy format
let needsPatch = versionMajor server_version == 4
sendRequest rawConn tout $ RHello $ Hello user_agent authScheme routing needsPatch
response <- receiveResponse rawConn tout
case response of
RSuccess meta -> do
let agent = psToText $ H.lookupDefault (PsString "") "server" meta
let conn_id = psToText $ H.lookupDefault (PsString "") "connection_id" meta
let idleTO = parseIdleTimeoutHint meta
pure (server_version, agent, conn_id, False, idleTO)
_ -> throwError AuthentificationFailed
where
psToText (PsString t) = t
psToText _ = ""
-- Parse hints.telemetry.enabled from HELLO SUCCESS metadata
parseTelemetryHint :: H.HashMap T.Text Ps -> Bool
parseTelemetryHint meta =
case H.lookup "hints" meta of
Just (PsDictionary hints) ->
case H.lookup "telemetry.enabled" hints of
Just (PsBoolean b) -> b
_ -> False
_ -> False
-- Parse hints.connection.recv_timeout_seconds from HELLO SUCCESS metadata
parseIdleTimeoutHint :: H.HashMap T.Text Ps -> Maybe Int
parseIdleTimeoutHint meta =
case H.lookup "hints" meta of
Just (PsDictionary hints) ->
case H.lookup "connection.recv_timeout_seconds" hints of
Just (PsInteger n) -> fmap fromIntegral (fromPSInteger n :: Maybe Int64)
_ -> Nothing
_ -> Nothing
-- | Check if a server version response matches any of the client's version specs.
-- The client sends compact version specs: word32 = [0, range, minor, major].
-- The server responds with: word32 = [0, 0, minor, major].
-- A server version matches a spec if the majors match and the server minor
-- falls in [spec_minor - range, spec_minor].
versionAccepted :: Word32 -> [Word32] -> Bool
versionAccepted serverVer = any matches
where
serverMajor = fromIntegral (serverVer .&. 0xFF) :: Word8
serverMinor = fromIntegral ((serverVer `shiftR` 8) .&. 0xFF) :: Word8
matches spec =
let specMajor = fromIntegral (spec .&. 0xFF) :: Word8
specMinor = fromIntegral ((spec `shiftR` 8) .&. 0xFF) :: Word8
specRange = fromIntegral ((spec `shiftR` 16) .&. 0xFF) :: Word8
in serverMajor == specMajor
&& serverMinor <= specMinor
&& serverMinor >= specMinor - specRange
-- | Receive and decode a chunked BOLT response from the wire.
receiveResponse :: MonadPipe m => NC.Connection -> Int -> m Response
receiveResponse rawConn tout = do
bs :: BS.ByteString <- whileJustM $ do
size :: Word16 <- C.receiveBinary rawConn tout 2
if size == 0 then
pure Nothing
else
Just <$> C.receiveBytestring rawConn tout (fromIntegral size)
case PS.unpack' bs of
Left e -> throwError $ CannotReadResponse e
Right response -> pure response
-- | Serialize and send a BOLT request as chunked data on the wire.
sendRequest :: MonadPipe m => NC.Connection -> Int -> Request -> m ()
sendRequest rawConn tout request = sendPs rawConn tout (PS.toPs request)
sendPs :: MonadPipe m => NC.Connection -> Int -> Ps -> m ()
sendPs rawConn tout ps = do
let bs = PS.pack ps
let chunks = chunksOfBSL 65_535 bs
let encoded = BSL.toStrict $ mconcat (map addChunkHeader chunks) <> BSL.pack [0, 0]
C.send rawConn tout encoded
where addChunkHeader :: BSL.ByteString -> BSL.ByteString
addChunkHeader chunk = let size = fromIntegral (BSL.length chunk) :: Word16
in runPutLazy (putBE size) <> chunk
-- | Send a request through a 'Connection'.
flush :: MonadPipe m => Connection -> Request -> m ()
flush Connection{rawConnection, timeout_milliseconds} request =
sendRequest rawConnection timeout_milliseconds request
-- | Receive the next response from a 'Connection'.
fetch :: MonadPipe m => Connection -> m Response
fetch Connection{rawConnection, timeout_milliseconds} =
receiveResponse rawConnection timeout_milliseconds
-- | Send LOGOFF, expect SUCCESS, transition to Authentication state.
-- Only valid on Bolt 5.1+ connections in Ready state.
logoff :: (MonadIO m, HasCallStack) => Connection -> m ()
logoff Connection{rawConnection, timeout_milliseconds, server_state} = exceptToThrow $ do
sendRequest rawConnection timeout_milliseconds RLogoff
response <- receiveResponse rawConnection timeout_milliseconds
case response of
RSuccess _ -> liftIO $ writeIORef server_state Authentication
_ -> throwError $ WrongMessageFormat "Expected SUCCESS after LOGOFF"
-- | Send LOGON with credentials, expect SUCCESS, transition to Ready state.
-- Only valid on Bolt 5.1+ connections in Authentication state.
logon :: (MonadIO m, HasCallStack) => Connection -> Scheme -> m ()
logon Connection{rawConnection, timeout_milliseconds, server_state} authScheme = exceptToThrow $ do
sendRequest rawConnection timeout_milliseconds $ RLogon $ Logon authScheme
response <- receiveResponse rawConnection timeout_milliseconds
case response of
RSuccess _ -> liftIO $ writeIORef server_state Ready
_ -> throwError AuthentificationFailed
-- | Send a TELEMETRY message if the server supports it.
-- No-op if telemetry is not enabled or version < 5.4.
sendTelemetry :: (MonadIO m, HasCallStack) => Connection -> TelemetryApi -> m ()
sendTelemetry Connection{telemetry_enabled, version, rawConnection, timeout_milliseconds} api
| telemetry_enabled && supportsTelemetry version = exceptToThrow $ do
sendRequest rawConnection timeout_milliseconds $ RTelemetry api
response <- receiveResponse rawConnection timeout_milliseconds
case response of
RSuccess _ -> pure ()
_ -> throwError $ WrongMessageFormat "Expected SUCCESS after TELEMETRY"
| otherwise = pure ()
-- ---------------------------------------------------------------------------
-- Connection accessors
-- ---------------------------------------------------------------------------
-- | Get the negotiated BOLT protocol version.
connectionVersion :: Connection -> Word32
connectionVersion Connection{version} = version
-- | Get the server agent string.
connectionAgent :: Connection -> T.Text
connectionAgent Connection{server_agent} = server_agent
-- | Get the server-assigned connection ID.
connectionId :: Connection -> T.Text
connectionId Connection{connection_id} = connection_id
-- | Get the server-advertised idle timeout in seconds, if any.
connectionServerIdleTimeout :: Connection -> Maybe Int
connectionServerIdleTimeout Connection{serverIdleTimeout} = serverIdleTimeout
-- | Check whether the server supports telemetry.
connectionTelemetryEnabled :: Connection -> Bool
connectionTelemetryEnabled Connection{telemetry_enabled} = telemetry_enabled
-- | Update the connection's last-activity timestamp to now.
touchConnection :: MonadIO m => Connection -> m ()
touchConnection Connection{lastActivity} = liftIO $ do
now <- getMonotonicTimeNSec
writeIORef lastActivity now
-- | Get the monotonic timestamp (in nanoseconds) of the last connection activity.
connectionLastActivity :: MonadIO m => Connection -> m Word64
connectionLastActivity Connection{lastActivity} = liftIO $ readIORef lastActivity
-- ---------------------------------------------------------------------------
-- Transaction primitives (plain IO)
-- ---------------------------------------------------------------------------
-- | Begin an explicit transaction with bookmarks and access mode.
beginTx :: HasCallStack => Connection -> Begin -> IO ()
beginTx conn@Connection{rawConnection, timeout_milliseconds, server_state} params = exceptToThrow $ do
requireState conn [Ready] "BEGIN"
sendRequest rawConnection timeout_milliseconds $ RBegin params
response <- receiveResponse rawConnection timeout_milliseconds
case response of
RSuccess _ -> liftIO $ writeIORef server_state TXready
RFailure Failure{code, message} -> do
liftIO $ writeIORef server_state Failed
liftIO $ reset conn
throwError $ ResponseErrorFailure code message
_ -> do
liftIO $ reset conn
throwError $ WrongMessageFormat "Unexpected response to BEGIN"
-- | Commit the current transaction and return the bookmark (if any).
commitTx :: HasCallStack => Connection -> IO (Maybe T.Text)
commitTx conn@Connection{rawConnection, timeout_milliseconds, server_state} = exceptToThrow $ do
requireState conn [TXready] "COMMIT"
sendRequest rawConnection timeout_milliseconds RCommit
response <- receiveResponse rawConnection timeout_milliseconds
case response of
RSuccess meta -> do
liftIO $ writeIORef server_state Ready
pure $ extractBookmark meta
RFailure Failure{code, message} -> do
liftIO $ writeIORef server_state Failed
liftIO $ reset conn
throwError $ ResponseErrorFailure code message
_ -> do
liftIO $ reset conn
throwError $ WrongMessageFormat "Unexpected response to COMMIT"
-- | Rollback the current transaction.
rollbackTx :: HasCallStack => Connection -> IO ()
rollbackTx conn@Connection{rawConnection, timeout_milliseconds, server_state} = exceptToThrow $ do
requireState conn [TXready] "ROLLBACK"
sendRequest rawConnection timeout_milliseconds RRollback
response <- receiveResponse rawConnection timeout_milliseconds
case response of
RSuccess _ -> liftIO $ writeIORef server_state Ready
RFailure Failure{code, message} -> do
liftIO $ writeIORef server_state Failed
liftIO $ reset conn
throwError $ ResponseErrorFailure code message
_ -> do
liftIO $ reset conn
throwError $ WrongMessageFormat "Unexpected response to ROLLBACK"
-- | Try to rollback the current transaction, ignoring errors.
-- Used as cleanup in onException handlers.
tryRollback :: Connection -> IO ()
tryRollback conn = do
st <- getState conn
when (st == TXready || st == TXstreaming) $
catch (rollbackTx conn) handler
where
handler :: Error -> IO ()
handler _ = pure ()
-- ---------------------------------------------------------------------------
-- Plain IO wire helpers
-- ---------------------------------------------------------------------------
-- | Check the connection state, throw InvalidState if wrong.
requireStateIO :: HasCallStack => Connection -> [ServerState] -> T.Text -> IO ()
requireStateIO conn allowed action = do
st <- getState conn
unless (st `elem` allowed) $ throwIO $ InvalidState st action
-- | Send a request, throwing on wire error.
flushIO :: HasCallStack => Connection -> Request -> IO ()
flushIO Connection{rawConnection, timeout_milliseconds} req =
exceptToThrow $ sendRequest rawConnection timeout_milliseconds req
-- | Receive a response, throwing on wire error.
fetchIO :: HasCallStack => Connection -> IO Response
fetchIO Connection{rawConnection, timeout_milliseconds} =
exceptToThrow $ receiveResponse rawConnection timeout_milliseconds