valiant-0.1.0.0: src/Valiant/Streaming.hs
{-# OPTIONS_GHC -fno-full-laziness #-}
-- | Streaming query results using server-side cursors.
--
-- Instead of fetching all rows at once, a cursor fetches rows in batches,
-- allowing processing of large result sets without loading everything into
-- memory. Must be called inside a transaction.
--
-- Cursors require exclusive access to the connection's wire, so the entire
-- cursor lifecycle runs inside 'submitExclusive'.
--
-- @
-- 'Valiant.Transaction.withTransaction' pool $ \\tx -> do
-- 'withCursor' (txConn tx) myQuery params 100 $ \\cursor -> do
-- let loop = do
-- batch <- 'fetchBatch' cursor 100
-- unless (null batch) $ do
-- mapM_ processRow batch
-- loop
-- loop
-- @
module Valiant.Streaming
( withCursor
, fetchBatch
, fetchAllCursor
, CursorState
) where
import Control.Exception (SomeException, catch, onException)
import Control.Monad (when)
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 Data.Word (Word64)
import PgWire.Async (submitExclusive)
import PgWire.Connection (Connection (..))
import PgWire.Error (PgWireError (..), throwPgWire)
import PgWire.Protocol.Backend
import PgWire.Protocol.Frontend
import PgWire.Protocol.Oid qualified as Oid
import Valiant.Statement (Statement (..))
import PgWire.Wire (WireConn, recvBackendMsg, sendFrontendMsg, sendFrontendMsgs)
import System.IO.Unsafe (unsafePerformIO)
-- | State of an open cursor, valid only within the 'withCursor' callback.
-- Do not retain references to this value outside the callback scope.
data CursorState = CursorState
{ csName :: ByteString
-- ^ The cursor name on the server.
, csWire :: WireConn
-- ^ Direct wire access (valid only inside exclusive mode).
, csTxRef :: IORef TxStatus
-- ^ Transaction status ref.
, csExhausted :: IORef Bool
-- ^ Whether the cursor has returned all rows.
, csPreparedBatch :: IORef Int
-- ^ Batch size currently baked into the unnamed prepared FETCH
-- statement on the server. 'fetchBatch' re-parses only when the
-- requested size differs from this value.
}
-- | Open a parameterized cursor for a statement, run an action, then close.
--
-- The statement's parameters are bound via the extended query protocol
-- (Parse\/Bind), so parameterized queries work correctly.
--
-- Must be called inside a transaction. The entire cursor lifecycle runs
-- in exclusive mode (direct socket access).
withCursor
:: Connection
-> Statement p r
-> p
-> Int
-- ^ Batch size hint (used as default for 'fetchBatch')
-> (CursorState -> IO a)
-> IO a
withCursor conn stmt params batchSize action =
submitExclusive (connAsync conn) $ \wc txRef -> do
cursorName <- freshCursorName
-- Declare the cursor and prepare a FETCH statement for the hinted
-- batch size in a single pipelined round-trip. Subsequent
-- 'fetchBatch' calls reuse the unnamed prepared FETCH as long as
-- the requested batch size matches.
let declareSql = "DECLARE " <> cursorName <> " NO SCROLL CURSOR FOR " <> stmtSQL stmt
fetchSql = "FETCH FORWARD " <> BS8.pack (show batchSize) <> " FROM " <> cursorName
encodedParams = stmtEncode stmt params
paramOids = V.map Oid.unOid (stmtParamOids stmt)
sendFrontendMsgs wc
[ Parse "" declareSql paramOids
, Bind "" "" (V.singleton BinaryFormat) encodedParams V.empty
, Execute "" 0
, Parse "" fetchSql V.empty
, Sync
]
waitDeclareComplete wc txRef
exhausted <- newIORef False
preparedBatch <- newIORef batchSize
let cs = CursorState cursorName wc txRef exhausted preparedBatch
closeCursor =
(do sendFrontendMsg wc (Query ("CLOSE " <> cursorName))
collectSimpleDiscard wc txRef
) `catch` \(_ :: SomeException) -> pure ()
result <- action cs `onException` closeCursor
closeCursor
pure result
-- | Fetch the next batch of rows from a cursor.
--
-- Returns an empty list when the cursor is exhausted. Subsequent calls
-- after exhaustion return empty immediately without a round-trip.
fetchBatch :: CursorState -> Int -> IO [Vector (Maybe ByteString)]
fetchBatch cs n = do
done <- readIORef (csExhausted cs)
if done
then pure []
else do
prepared <- readIORef (csPreparedBatch cs)
let bindExecSync =
[ Bind "" "" V.empty V.empty (V.singleton BinaryFormat)
, Execute "" 0
, Sync
]
msgs
| prepared == n = bindExecSync
| otherwise =
let fetchSql = "FETCH FORWARD " <> BS8.pack (show n) <> " FROM " <> csName cs
in Parse "" fetchSql V.empty : bindExecSync
when (prepared /= n) $ writeIORef (csPreparedBatch cs) n
sendFrontendMsgs (csWire cs) msgs
rows <- collectFetchResults (csWire cs) (csTxRef cs)
if null rows
then do
writeIORef (csExhausted cs) True
pure []
else pure rows
-- | Open a cursor, drain every batch, decode and return all rows.
--
-- Convenience wrapper used by streaming adapters that need to materialise
-- a cursor result as a list. Must be called inside a transaction.
fetchAllCursor :: Connection -> Statement p r -> p -> Int -> IO [r]
fetchAllCursor conn stmt params batchSize =
withCursor conn stmt params batchSize $ \cs ->
-- Accumulate batches as a list-of-lists, concat once at the end.
-- This is O(N) total work, vs ~3-5x more with `reverse b ++ acc`,
-- because we avoid reversing each batch and the final list.
let go !acc = do
batch <- fetchBatch cs batchSize
if null batch
then pure (concat (reverse acc))
else do
decoded <- mapM (decodeRow (stmtDecode stmt)) batch
go (decoded : acc)
in go []
where
decodeRow decode row = case decode row of
Left err -> throwPgWire (DecodeError (BS8.pack err))
Right !val -> pure val
-- Internal ------------------------------------------------------------------
waitDeclareComplete :: WireConn -> IORef TxStatus -> IO ()
waitDeclareComplete wc txRef = go
where
go = do
msg <- recvBackendMsg wc
case msg of
ParseComplete -> go
BindComplete -> go
CommandComplete _ -> go
ReadyForQuery status -> writeIORef txRef status
ErrorResponse err -> throwPgWire (QueryError err)
NoticeResponse _ -> go
other -> throwPgWire (ProtocolError ("Unexpected in DECLARE cursor: " <> BS8.pack (show other)))
collectFetchResults :: WireConn -> IORef TxStatus -> IO [Vector (Maybe ByteString)]
collectFetchResults wc txRef = go []
where
go !acc = do
msg <- recvBackendMsg wc
case msg of
ParseComplete -> go acc
BindComplete -> go acc
RowDescription _ -> go acc
DataRow vals -> go (vals : acc)
CommandComplete _ -> go acc
EmptyQueryResponse -> go acc
ReadyForQuery status -> do
writeIORef txRef status
pure (reverse acc)
ErrorResponse err -> throwPgWire (QueryError err)
NoticeResponse _ -> go acc
other -> throwPgWire (ProtocolError ("Unexpected in cursor fetch: " <> BS8.pack (show other)))
-- | Discard all results from a simple query.
collectSimpleDiscard :: WireConn -> IORef TxStatus -> IO ()
collectSimpleDiscard wc txRef = go
where
go = do
msg <- recvBackendMsg wc
case msg of
ReadyForQuery status -> writeIORef txRef status
ErrorResponse err -> throwPgWire (QueryError err)
_ -> go
-- | Global counter for unique cursor names.
{-# NOINLINE cursorCounter #-}
cursorCounter :: IORef Word64
cursorCounter = unsafePerformIO (newIORef 0)
freshCursorName :: IO ByteString
freshCursorName = do
n <- atomicModifyIORef' cursorCounter (\n -> (n + 1, n))
pure ("valiant_cur_" <> BS8.pack (show n))