valiant-0.1.0.0: src/Valiant/Pipeline.hs
{-# LANGUAGE ExistentialQuantification #-}
{-# LANGUAGE LambdaCase #-}
{-# OPTIONS_GHC -fno-full-laziness #-}
-- | Pipelined query execution for batching multiple independent queries
-- into a single network round-trip.
--
-- The PostgreSQL extended query protocol allows sending multiple
-- Bind+Execute sequences before a single Sync. This module provides
-- an 'Applicative' interface for composing independent queries that
-- are sent together and collected in order.
--
-- @
-- (user, posts) <- 'runPipeline' conn $ (,)
-- '<$>' 'pipeFetchAll' listPostsByUser 42
-- '<*>' 'pipeFetchOne' findUserById 42
-- -- Both queries sent in one round-trip, results collected in order
-- @
--
-- This eliminates the N+1 query problem at the driver level.
module Valiant.Pipeline
( Pipeline
, pipeFetchOne
, pipeFetchAll
, pipeFetchScalar
, pipeExecute
, runPipeline
) where
import Data.ByteString (ByteString)
import Data.ByteString.Char8 qualified as BS8
import Data.IORef
import Data.Vector (Vector)
import Data.Vector qualified as V
import PgWire.Async (Request (..), Response (..), ResponseCollector (..), submitRequest)
import PgWire.Connection (Connection (..))
import PgWire.Error (PgWireError (..), throwPgWire)
import PgWire.Protocol.Frontend
import Valiant.Execute (ensurePrepared)
import Valiant.Statement (Statement (..))
-- | A pipeline of queries to execute in a single round-trip.
-- Use the 'Applicative' interface to compose independent queries.
data Pipeline a
= PureP a
| forall p r. QueryP
(Statement p r)
p
([Vector (Maybe ByteString)] -> IO a)
-- ^ Decoder: takes collected rows and produces the result
| forall b. ApP (Pipeline (b -> a)) (Pipeline b)
instance Functor Pipeline where
fmap f (PureP a) = PureP (f a)
fmap f (QueryP s p dec) = QueryP s p (fmap f . dec)
fmap f (ApP pf px) = ApP (fmap (f .) pf) px
instance Applicative Pipeline where
pure = PureP
(<*>) = ApP
-- | Pipeline a query that returns zero or one row.
pipeFetchOne :: Statement p r -> p -> Pipeline (Maybe r)
pipeFetchOne stmt params = QueryP stmt params $ \case
[] -> pure Nothing
(row : _) -> case stmtDecode stmt row of
Left err -> throwPgWire (DecodeError (BS8.pack err))
Right val -> pure (Just val)
{-# INLINE pipeFetchOne #-}
-- | Pipeline a query that returns all rows.
pipeFetchAll :: Statement p r -> p -> Pipeline [r]
pipeFetchAll stmt params = QueryP stmt params $ \rows ->
mapM (\row -> case stmtDecode stmt row of
Left err -> throwPgWire (DecodeError (BS8.pack err))
Right val -> pure val) rows
{-# INLINE pipeFetchAll #-}
-- | Pipeline a query that returns a single scalar value.
pipeFetchScalar :: Statement p r -> p -> Pipeline r
pipeFetchScalar stmt params = QueryP stmt params $ \case
[row] -> case stmtDecode stmt row of
Left err -> throwPgWire (DecodeError (BS8.pack err))
Right val -> pure val
[] -> throwPgWire (DecodeError "pipeFetchScalar: no rows")
_ -> throwPgWire (DecodeError "pipeFetchScalar: more than one row")
{-# INLINE pipeFetchScalar #-}
-- | Pipeline a command (INSERT\/UPDATE\/DELETE).
--
-- Row counts are not available in pipeline mode because the batch collector
-- does not track individual command tags. Use 'Valiant.Execute.execute' or
-- 'Valiant.Execute.executeBatch' outside a pipeline if you need the
-- rows-affected count.
pipeExecute :: Statement p () -> p -> Pipeline ()
pipeExecute stmt params = QueryP stmt params $ \_ -> pure ()
{-# INLINE pipeExecute #-}
-- | Execute all queries in the pipeline in a single network round-trip.
--
-- @
-- (user, posts, count) <- runPipeline conn $ (,,)
-- '<$>' pipeFetchOne findUserById 42
-- '<*>' pipeFetchAll listRecentPosts 10
-- '<*>' pipeFetchScalar countUsers ()
-- @
runPipeline :: Connection -> Pipeline a -> IO a
runPipeline conn pipeline = do
let queries = collectQueries pipeline
case queries of
[] -> evalPipeline pipeline []
_ -> do
-- Ensure all statements are prepared
names <- mapM (ensurePreparedPipe conn) queries
-- Build all Bind+Execute messages with one Sync
let msgs = concatMap (\(PipeQuery stmt p, name) ->
let encodedParams = stmtEncode stmt p
in [ Bind "" name (V.singleton BinaryFormat) encodedParams (V.singleton BinaryFormat)
, Execute "" 0
]) (zip queries names)
++ [Sync]
-- Submit as a single batch request
resp <- submitRequest (connAsync conn) $ ReqExtendedQuery msgs (CollectBatch (length queries))
case resp of
RespBatchRows results -> evalPipeline pipeline results
_ -> throwPgWire (ProtocolError "runPipeline: unexpected response type")
-- Internal ----------------------------------------------------------------
data PipeQuery = forall p r. PipeQuery (Statement p r) p
collectQueries :: Pipeline a -> [PipeQuery]
collectQueries (PureP _) = []
collectQueries (QueryP stmt params _) = [PipeQuery stmt params]
collectQueries (ApP pf px) = collectQueries pf ++ collectQueries px
evalPipeline :: Pipeline a -> [[Vector (Maybe ByteString)]] -> IO a
evalPipeline pipeline allResults = do
ref <- newIORef allResults
eval ref pipeline
eval :: IORef [[Vector (Maybe ByteString)]] -> Pipeline a -> IO a
eval _ (PureP a) = pure a
eval ref (QueryP _ _ decode) = do
rs <- readIORef ref
case rs of
(rows : rest) -> do
writeIORef ref rest
decode rows
[] -> throwPgWire (DecodeError "pipeline: result underflow")
eval ref (ApP pf px) = do
f <- eval ref pf
x <- eval ref px
pure (f x)
ensurePreparedPipe :: Connection -> PipeQuery -> IO ByteString
ensurePreparedPipe conn (PipeQuery stmt _) = ensurePrepared conn stmt