packages feed

hasql 1.10.3.6 → 1.10.3.7

raw patch · 4 files changed

+242/−2 lines, 4 filesPVP ok

version bump matches the API change (PVP)

API changes (from Hackage documentation)

Files

hasql.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.0 name: hasql-version: 1.10.3.6+version: 1.10.3.7 category: Hasql, Database, PostgreSQL synopsis: Fast PostgreSQL driver with a flexible mapping API description:@@ -326,6 +326,7 @@     Isolated.ByUnit.Connection.AcquireSpec     Pure.ByUnit.ErrorsSpec     Sharing.ByBug.ExceptionConnectionResetRaceSpec+    Sharing.ByBug.PipelineAbortedInterruptionCleanupSpec     Sharing.ByFeature.ConcurrencySpec     Sharing.ByFeature.DecoderCompatibilityCacheSpec     Sharing.ByFeature.DecoderCompatibilityCheckSpec
src/comms-tests/Hasql/Comms/Session/CleanUpAfterInterruptionSpec.hs view
@@ -27,6 +27,91 @@         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@@ -106,6 +191,24 @@           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 =
+ src/library-tests/Sharing/ByBug/PipelineAbortedInterruptionCleanupSpec.hs view
@@ -0,0 +1,134 @@+module Sharing.ByBug.PipelineAbortedInterruptionCleanupSpec (spec) where++import Data.Text qualified as Text+import Hasql.Connection qualified as Connection+import Hasql.Decoders qualified as Decoders+import Hasql.Encoders qualified as Encoders+import Hasql.Pipeline qualified as Pipeline+import Hasql.Session qualified as Session+import Hasql.Statement qualified as Statement+import Helpers.Scripts qualified as Scripts+import Test.Hspec+import Prelude++-- | A statement that sleeps for the given number of seconds and succeeds.+--+-- Used to widen the wall-clock window during which the client is blocked+-- waiting on the network/socket for a pipelined result, so that an+-- asynchronous interruption has a realistic chance of landing right around+-- the moment the *next* statement in the same pipeline fails.+sleepStatement :: Statement.Statement Double ()+sleepStatement =+  Statement.preparable+    "select pg_sleep($1)"+    (Encoders.param (Encoders.nonNullable Encoders.float8))+    Decoders.noResult++-- | A statement that is guaranteed to fail on the server.+failingStatement :: Statement.Statement () ()+failingStatement =+  Statement.preparable+    "select 1/0"+    Encoders.noParams+    Decoders.noResult++-- | A pipeline of two statements: the first sleeps and succeeds, the+-- second fails. Executing this via 'Session.pipeline' drives libpq's+-- pipeline status through: Off -> On -> (once the sleep result has been+-- received and the divide error has been processed) Aborted -> (normally)+-- Off again, via the exit sequence inside 'toPipelineIO'.+--+-- The bug under test concerns what happens if an asynchronous exception+-- interrupts execution during the narrow "Aborted" window: right after+-- libpq has registered the error result for the second statement (which+-- flips its internal pipeline status to `PipelineAborted`) but before the+-- driver has drained the trailing pipeline-sync marker and called+-- `exitPipelineMode`. That window is only a couple of FFI calls wide, so+-- reliably landing an async exception inside it requires many attempts+-- across a fine-grained sweep of interrupt delays (see 'spec' below).+--+-- Note: an earlier version of this test tried to widen the window by+-- appending many trivial "filler" statements after the failing one (on+-- the theory that draining their results would take measurably longer).+-- That approach reproduced failures reliably, but for the wrong reason:+-- `Comms.Session.drainResults` only drains one queued command's worth of+-- results per call, so a large backlog of undrained filler results made+-- `exitPipelineMode` fail with "cannot exit pipeline mode with uncollected+-- results" regardless of whether the `PipelineOn`/`PipelineAborted` bug+-- under test was present or fixed. That's a real, separate limitation of+-- `drainResults`, but not the bug this test is about, so the pipeline here+-- is kept to exactly two statements and 'attempt' below specifically+-- checks for the "not allowed in pipeline mode" signature (the one the+-- one-line `leavePipeline` fix actually addresses) rather than any+-- "Failed to clean up after interruption" message.+racingPipelineSession :: Double -> Session.Session ()+racingPipelineSession sleepSeconds =+  Session.pipeline do+    Pipeline.statement sleepSeconds sleepStatement+      *> Pipeline.statement () failingStatement++-- | Try once to reproduce the bug: acquire a fresh connection, race a+-- `timeout` against the pipelined session (sleep-then-fail) tuned to fire+-- right around the moment the pipeline transitions to the aborted state,+-- and report whether `Connection.use` came back with the specific driver+-- error that signals the `leavePipeline` bug: it only checks for+-- `PipelineOn`, so when the connection is genuinely `PipelineAborted` at+-- interruption time, cleanup skips leaving the pipeline and falls through+-- to `bringTransactionStatusToIdle`, which tries to send "ABORT" as a+-- serial command while still in pipeline mode -- something libpq flatly+-- refuses ("PQsendQuery not allowed in pipeline mode").+--+-- Note on why checking `Connection.use`'s own return value is enough: when+-- `timeout` throws its internal exception into the thread running+-- `Connection.use`, that exception is caught by `use`'s own+-- @try \@SomeException@. If the bug is NOT triggered, `use` cleans up+-- successfully and rethrows the very same timeout exception, so `timeout`+-- observes it and returns 'Nothing'. If the bug IS triggered, `use`+-- reports the cleanup failure as an ordinary `Left (DriverSessionError _)`+-- return value instead of rethrowing, so `timeout` observes a normal+-- return and reports 'Just (Left _)'.+attempt :: (Text, Word16) -> Double -> Int -> IO (Maybe Text)+attempt config sleepSeconds delayMicros =+  Scripts.onPreparableConnection config \connection -> do+    result <- timeout delayMicros do+      Connection.use connection (racingPipelineSession sleepSeconds)+    pure case result of+      Just (Left err) ->+        let rendered = Text.pack (show err)+         in if "Failed to clean up after interruption"+              `Text.isInfixOf` rendered+              && "not allowed in pipeline mode"+              `Text.isInfixOf` rendered+              then Just rendered+              else Nothing+      Just (Right ()) -> Nothing+      Nothing -> Nothing++spec :: SpecWith (Text, Word16)+spec = do+  describe "Interruption of a pipeline while it is in the Aborted status" do+    it "Connection.use recovers cleanly instead of reporting a driver cleanup failure" \config -> do+      -- We sweep the timeout across a window that straddles the moment the+      -- sleep statement finishes and the failing statement's error result+      -- gets processed by libpq (which is when the pipeline status flips+      -- from `PipelineOn` to `PipelineAborted`). The genuinely vulnerable+      -- window is only a couple of FFI calls wide (nowhere near as wide as+      -- our timer granularity), so we compensate with a large number of+      -- attempts spread finely across the window and a fresh connection+      -- each time, rather than trying to widen the window itself.+      let sleepMicros = 20000 :: Int -- 20ms sleep statement duration+          sleepSeconds = fromIntegral sleepMicros / 1000000+          delays = [sleepMicros + step | step <- [(-3000), (-2900) .. 6000]]+          attemptsPerDelay = 15++      results <-+        sequence+          [ attempt config sleepSeconds d+          | d <- delays,+            _ <- [1 :: Int .. attemptsPerDelay]+          ]++      let reproductions = [msg | Just msg <- results]++      reproductions+        `shouldBe` []
src/library/Hasql/Comms/Session.hs view
@@ -75,7 +75,9 @@ leavePipeline :: Session () leavePipeline = do   pipelineStatus <- getPipelineStatus-  when (pipelineStatus == Pq.PipelineOn) do+  -- PipelineAborted is still pipeline mode. It must reach a sync point before+  -- libpq permits serial queries such as ABORT or DEALLOCATE ALL again.+  when (pipelineStatus /= Pq.PipelineOff) do     -- In pipeline mode, we need to ensure the pipeline is synchronized before exiting.     -- Send a pipeline sync marker to flush any pending operations.     syncSuccess <- sendPipelineSync