packages feed

hinagata-postgres-0.1.0.0: src/Hinagata/Postgres/Internal/Copy.hs

-- | Bounded COPY FROM STDIN with a single nonblocking libpq protocol owner.
module Hinagata.Postgres.Internal.Copy
  ( copyCsv,
  )
where

import Control.Exception (IOException, try)
import Crypto.Hash.SHA256 qualified as SHA256
import Data.ByteString (ByteString)
import Data.ByteString qualified as ByteString
import Data.Text qualified as Text
import Data.Text.Encoding qualified as Encoding
import Data.Word (Word64)
import Database.PostgreSQL.LibPQ qualified as PQ
import Hinagata.Fixture.Bundle (CapturedStep (..))
import Hinagata.Fixture.Types (CopySpec (..))
import Hinagata.Postgres.Error
import Hinagata.Postgres.Internal.Libpq
import Hinagata.Prelude
import Hinagata.Types (quoteSqlIdentifier)
import Numeric (showHex)
import System.IO (IOMode (ReadMode), withBinaryFile)

copyCsv :: PQ.Connection -> Deadline -> FilePath -> CapturedStep -> IO (Either NativeError Word64)
copyCsv connection deadline path step =
  case step of
    CapturedCopy {copySpec, contentDigest, byteLength} -> do
      begun <- startCopy connection deadline copySpec
      case begun of
        Left failure -> pure (Left failure)
        Right () -> do
          transferred <- try @IOException (withBinaryFile path ReadMode (stream byteLength contentDigest))
          case transferred of
            Left _ -> do
              abortCopy connection deadline
              pure (Left (NativeError Copy Nothing "COPY bundle file could not be read"))
            Right (Left failure) -> do
              abortCopy connection deadline
              pure (Left failure)
            Right (Right count) -> do
              ended <- endCopy connection deadline
              pure (count <$ ended)
    _ -> pure (Left (NativeError Copy Nothing "expected captured CSV step"))
  where
    stream expected digest input = go input 0 SHA256.init
      where
        go handle count context = do
          bytes <- ByteString.hGet handle 65536
          if ByteString.null bytes
            then
              if count == expected && hex (SHA256.finalize context) == digest
                then pure (Right count)
                else pure (Left (NativeError Copy Nothing "COPY bundle bytes changed"))
            else do
              sent <- sendChunk connection deadline bytes
              case sent of
                Left failure -> pure (Left failure)
                Right () -> do
                  let nextCount = count + fromIntegral (ByteString.length bytes)
                      nextContext = SHA256.update context bytes
                  nextContext `seq` go handle nextCount nextContext

startCopy :: PQ.Connection -> Deadline -> CopySpec -> IO (Either NativeError ())
startCopy connection deadline spec = do
  sent <- PQ.sendQuery connection (renderCopy spec)
  if not sent
    then pure (Left (NativeError Copy Nothing "libpq refused COPY dispatch"))
    else do
      flushed <- flushOutput connection deadline
      if not flushed
        then pure (Left (NativeError Copy Nothing "COPY dispatch timed out"))
        else do
          ready <- awaitResult connection deadline
          if not ready
            then pure (Left (NativeError Copy Nothing "COPY start timed out"))
            else do
              result <- PQ.getResult connection
              case result of
                Nothing -> pure (Left (NativeError Copy Nothing "COPY returned no start result"))
                Just value -> do
                  status <- PQ.resultStatus value
                  if status == PQ.CopyIn
                    then pure (Right ())
                    else do
                      state <- PQ.resultErrorField value PQ.DiagSqlstate
                      _ <- drainResults connection deadline Copy
                      pure (Left (NativeError Copy (Encoding.decodeUtf8 <$> state) ("COPY start returned " <> Text.pack (show status))))

sendChunk :: PQ.Connection -> Deadline -> ByteString -> IO (Either NativeError ())
sendChunk connection deadline bytes = do
  outcome <- PQ.putCopyData connection bytes
  case outcome of
    PQ.CopyInOk -> do
      -- Nonblocking libpq queues successful COPY writes until PQflush. Drain
      -- each bounded chunk so client memory cannot grow with fixture size.
      flushed <- flushOutput connection deadline
      pure $ if flushed then Right () else Left (NativeError Copy Nothing "COPY data flush timed out")
    PQ.CopyInError -> pure (Left (NativeError Copy Nothing "COPY data transfer failed"))
    PQ.CopyInWouldBlock -> do
      flushed <- flushOutput connection deadline
      if not flushed
        then pure (Left (NativeError Copy Nothing "COPY data flush timed out"))
        else do
          ready <- awaitWritable connection deadline
          if ready then sendChunk connection deadline bytes else pure (Left (NativeError Copy Nothing "COPY transfer timed out"))

endCopy :: PQ.Connection -> Deadline -> IO (Either NativeError ())
endCopy connection deadline = do
  outcome <- PQ.putCopyEnd connection Nothing
  case outcome of
    PQ.CopyInOk -> do
      flushed <- flushOutput connection deadline
      if flushed then drainResults connection deadline Copy else pure (Left (NativeError Copy Nothing "COPY end flush timed out"))
    PQ.CopyInError -> pure (Left (NativeError Copy Nothing "COPY end failed"))
    PQ.CopyInWouldBlock -> do
      flushed <- flushOutput connection deadline
      if not flushed
        then pure (Left (NativeError Copy Nothing "COPY end flush timed out"))
        else do
          ready <- awaitWritable connection deadline
          if ready then endCopy connection deadline else pure (Left (NativeError Copy Nothing "COPY end timed out"))

abortCopy :: PQ.Connection -> Deadline -> IO ()
abortCopy connection deadline = do
  outcome <- PQ.putCopyEnd connection (Just "Hinagata bundle transfer failed")
  case outcome of
    PQ.CopyInOk -> do
      _ <- flushOutput connection deadline
      _ <- drainResults connection deadline Copy
      pure ()
    PQ.CopyInWouldBlock -> do
      flushed <- flushOutput connection deadline
      when flushed $ do
        ready <- awaitWritable connection deadline
        when ready (abortCopy connection deadline)
    PQ.CopyInError -> pure ()

renderCopy :: CopySpec -> ByteString
renderCopy CopySpec {schema, table, columns, header} =
  Encoding.encodeUtf8 $
    "COPY "
      <> quoteSqlIdentifier schema
      <> "."
      <> quoteSqlIdentifier table
      <> " ("
      <> Text.intercalate ", " (map quoteSqlIdentifier columns)
      <> ") FROM STDIN WITH (FORMAT csv, HEADER "
      <> (if header then "true" else "false")
      <> ")"

hex :: ByteString -> Text
hex = Text.pack . concatMap digits . ByteString.unpack
  where
    digits byte = case showHex byte "" of
      [digit] -> ['0', digit]
      value -> value