packages feed

hasql-1.10.3.7: src/comms-tests/Hasql/Comms/Session/CleanUpAfterInterruptionSpec.hs

module Hasql.Comms.Session.CleanUpAfterInterruptionSpec (spec) where

import Hasql.Comms.Session qualified as Session
import Hasql.Platform.Prelude
import Hasql.Pq qualified as Pq
import Test.Hspec
import TextBuilder qualified

spec :: SpecWith (Text, Word16)
spec = do
  describe "cleanUpAfterInterruption" do
    it "cleans up when in pipeline mode" \config -> do
      withConnection config \connection -> do
        -- Enter pipeline mode
        success <- Pq.enterPipelineMode connection
        success `shouldBe` True

        -- Verify we're in pipeline mode
        status <- Pq.pipelineStatus connection
        status `shouldBe` Pq.PipelineOn

        -- Run cleanup
        result <- Session.toHandler Session.cleanUpAfterInterruption connection
        result `shouldBe` Right ()

        -- Verify we're out of pipeline mode
        status <- Pq.pipelineStatus connection
        status `shouldBe` Pq.PipelineOff

    it "cleans up when the pipeline is aborted with no pending transaction or sync" \config -> do
      withConnection config \connection -> do
        success <- Pq.enterPipelineMode connection
        success `shouldBe` True

        sent <- Pq.sendQueryParams connection "SELECT * FROM nonexistent_table" [] Pq.Text
        sent `shouldBe` True
        flushRequestSent <- Pq.sendFlushRequest connection
        flushRequestSent `shouldBe` True
        flushStatus <- Pq.flush connection
        flushStatus `shouldBe` Pq.FlushOk

        let waitForResult attempts = do
              when (attempts <= 0) do
                expectationFailure "Timed out waiting for libpq result after flushing pipeline query"
              consumed <- Pq.consumeInput connection
              consumed `shouldBe` True
              busy <- Pq.isBusy connection
              if busy
                then threadDelay 1000 >> waitForResult (attempts - 1)
                else Pq.getResult connection

        result <- waitForResult (10000 :: Int)
        resultStatus <- traverse Pq.resultStatus result
        resultStatus `shouldBe` Just Pq.FatalError
        pipelineStatus <- Pq.pipelineStatus connection
        pipelineStatus `shouldBe` Pq.PipelineAborted

        cleanupResult <- Session.toHandler Session.cleanUpAfterInterruption connection
        cleanupResult `shouldBe` Right ()
        finalPipelineStatus <- Pq.pipelineStatus connection
        finalPipelineStatus `shouldBe` Pq.PipelineOff

    -- Deterministic counterpart to the timing-based reproduction in
    -- "Sharing.ByBug.PipelineAbortedInterruptionCleanupSpec": instead of
    -- racing an async exception into the narrow window during which libpq's
    -- pipeline is in the aborted state, we put the connection into that state
    -- directly and invoke the cleanup that an interruption would have
    -- invoked. This exercises the `leavePipeline` branch that used to only
    -- handle `PipelineOn`, leaving an aborted pipeline to fall through to
    -- `bringTransactionStatusToIdle`, which then tried to send "ABORT" as a
    -- serial command while still in pipeline mode.
    it "cleans up when the pipeline is aborted" \config -> do
      withConnection config \connection -> do
        -- Open a transaction, so that the failing statement below leaves the
        -- connection in the error transaction status -- the status that makes
        -- `bringTransactionStatusToIdle` want to send "ABORT" as a serial
        -- command, which libpq refuses while pipeline mode is still on
        _ <- Pq.exec connection "BEGIN"

        -- Enter pipeline mode
        success <- Pq.enterPipelineMode connection
        success `shouldBe` True

        -- Queue a statement that is guaranteed to fail on the server,
        -- followed by another one. The trailing statement matters: it makes
        -- the abort survive the drains that `cleanUpAfterInterruption`
        -- performs before reaching `leavePipeline`, since `drainResults`
        -- stops at each command boundary
        success <- Pq.sendQueryParams connection "select 1/0" [] Pq.Text
        success `shouldBe` True

        success <- Pq.sendQueryParams connection "select 1" [] Pq.Text
        success `shouldBe` True

        -- Flush the queued statements to the server
        success <- Pq.pipelineSync connection
        success `shouldBe` True

        -- Consume results until libpq registers the error and flips the
        -- pipeline into the aborted state, leaving the trailing sync marker
        -- undrained -- exactly the state an interruption would leave behind
        consumeUntilPipelineAborted connection

        -- Run cleanup
        result <- Session.toHandler Session.cleanUpAfterInterruption connection
        result `shouldBe` Right ()

        -- Verify we're out of pipeline mode and usable again
        status <- Pq.pipelineStatus connection
        status `shouldBe` Pq.PipelineOff

        transStatus <- Pq.transactionStatus connection
        transStatus `shouldBe` Pq.TransIdle

    it "cleans up when in an open transaction" \config -> do
      withConnection config \connection -> do
        -- Start a transaction
        _ <- Pq.exec connection "BEGIN"

        -- Verify we're in a transaction
        transStatus <- Pq.transactionStatus connection
        transStatus `shouldBe` Pq.TransInTrans

        -- Run cleanup
        result <- Session.toHandler Session.cleanUpAfterInterruption connection
        result `shouldBe` Right ()

        -- Verify transaction was aborted (idle state)
        transStatus <- Pq.transactionStatus connection
        transStatus `shouldBe` Pq.TransIdle

    it "cleans up when in error state within transaction" \config -> do
      withConnection config \connection -> do
        -- Start a transaction
        _ <- Pq.exec connection "BEGIN"

        -- Cause an error
        _ <- Pq.exec connection "SELECT * FROM nonexistent_table"

        -- Verify we're in error state
        transStatus <- Pq.transactionStatus connection
        transStatus `shouldBe` Pq.TransInError

        -- Run cleanup
        result <- Session.toHandler Session.cleanUpAfterInterruption connection
        result `shouldBe` Right ()

        -- Verify transaction was aborted (idle state)
        transStatus <- Pq.transactionStatus connection
        transStatus `shouldBe` Pq.TransIdle

    it "cleans up with pending results" \config -> do
      withConnection config \connection -> do
        -- Send a query without retrieving results
        success <- Pq.sendQuery connection "SELECT 1"
        success `shouldBe` True

        -- Run cleanup (should drain results)
        result <- Session.toHandler Session.cleanUpAfterInterruption connection
        result `shouldBe` Right ()

        -- Verify connection is in idle state
        transStatus <- Pq.transactionStatus connection
        transStatus `shouldBe` Pq.TransIdle

    it "cleans up with prepared statements" \config -> do
      withConnection config \connection -> do
        -- Create a prepared statement
        mResult <- Pq.prepare connection "test_stmt" "SELECT $1::int" Nothing
        case mResult of
          Just _ -> pure ()
          Nothing -> expectationFailure "Expected result from prepare"

        -- Consume any remaining results
        _ <- Pq.getResult connection

        -- Verify the prepared statement exists by using it
        _ <- Pq.execPrepared connection "test_stmt" [Just ("42", Pq.Text)] Pq.Text

        -- Run cleanup (should deallocate all statements)
        cleanupResult <- Session.toHandler Session.cleanUpAfterInterruption connection
        cleanupResult `shouldBe` Right ()

        -- Try to execute the prepared statement - it should fail now
        mResult2 <- Pq.execPrepared connection "test_stmt" [Just ("42", Pq.Text)] Pq.Text
        case mResult2 of
          Just result -> do
            status <- Pq.resultStatus result
            -- Should get an error status because the statement was deallocated
            status `shouldBe` Pq.FatalError
          Nothing -> expectationFailure "Expected error result from exec prepared after deallocation"

-- * Helpers

-- | Pull results from a pipelined connection until libpq reports the pipeline
-- as aborted, failing the example if that never happens.
consumeUntilPipelineAborted :: Pq.Connection -> IO ()
consumeUntilPipelineAborted connection =
  go (10 :: Int)
  where
    go attemptsLeft = do
      status <- Pq.pipelineStatus connection
      case status of
        Pq.PipelineAborted -> pure ()
        _
          | attemptsLeft <= 0 ->
              expectationFailure
                ("Pipeline did not reach the aborted state. Last status: " <> show status)
          | otherwise -> do
              _ <- Pq.getResult connection
              go (pred attemptsLeft)

withConnection :: (Text, Word16) -> (Pq.Connection -> IO a) -> IO a
withConnection (host, port) action =
  let connectionString =
        (encodeUtf8 . TextBuilder.toText . mconcat)
          [ "host=",
            TextBuilder.text host,
            " port=",
            TextBuilder.decimal port,
            " user=postgres",
            " password=postgres",
            " dbname=postgres"
          ]
   in bracket
        (Pq.connectdb connectionString)
        ( \connection -> do
            Pq.finish connection
        )
        ( \connection -> do
            status <- Pq.status connection
            case status of
              Pq.ConnectionOk -> action connection
              _ -> do
                errorMessage <- Pq.errorMessage connection
                fail ("Connection failed: " <> show errorMessage)
        )