packages feed

pgmq-effectful 0.6.1.1 → 0.7.0.0

raw patch · 13 files changed

+879/−56 lines, 13 filesdep ~ephemeral-pgdep ~pgmq-coredep ~pgmq-effectfulPVP ok

version bump matches the API change (PVP)

Dependency ranges changed: ephemeral-pg, pgmq-core, pgmq-effectful, pgmq-hasql, pgmq-migration

API changes (from Hackage documentation)

+ Pgmq.Effectful: ArchivedMessage :: !Message -> !UTCTime -> ArchivedMessage
+ Pgmq.Effectful: LookupMessage :: !Text -> !MessageId -> LookupMessage
+ Pgmq.Effectful: PeekMessages :: !Text -> !Maybe MessageId -> !Int32 -> PeekMessages
+ Pgmq.Effectful: [afterMessageId] :: PeekMessages -> !Maybe MessageId
+ Pgmq.Effectful: [archivedAt] :: ArchivedMessage -> !UTCTime
+ Pgmq.Effectful: [archivedMessage] :: ArchivedMessage -> !Message
+ Pgmq.Effectful: [limit] :: PeekMessages -> !Int32
+ Pgmq.Effectful: [unvalidatedQueueName] :: LookupMessage -> !Text
+ Pgmq.Effectful: data ArchivedMessage
+ Pgmq.Effectful: data LookupMessage
+ Pgmq.Effectful: data PeekMessages
+ Pgmq.Effectful: isAmbiguousReply :: PgmqRuntimeError -> Bool
+ Pgmq.Effectful: lookupArchivedMessage :: forall (es :: [Effect]). Pgmq :> es => LookupMessage -> Eff es (Maybe ArchivedMessage)
+ Pgmq.Effectful: lookupMessage :: forall (es :: [Effect]). Pgmq :> es => LookupMessage -> Eff es (Maybe Message)
+ Pgmq.Effectful: peekArchivedMessages :: forall (es :: [Effect]). Pgmq :> es => PeekMessages -> Eff es (Vector ArchivedMessage)
+ Pgmq.Effectful: peekMessages :: forall (es :: [Effect]). Pgmq :> es => PeekMessages -> Eff es (Vector Message)
+ Pgmq.Effectful: queueMetricsUnvalidated :: forall (es :: [Effect]). Pgmq :> es => Text -> Eff es QueueMetrics
+ Pgmq.Effectful.Effect: [LookupArchivedMessage] :: forall (a :: Type -> Type). LookupMessage -> Pgmq a (Maybe ArchivedMessage)
+ Pgmq.Effectful.Effect: [LookupMessage] :: forall (a :: Type -> Type). LookupMessage -> Pgmq a (Maybe Message)
+ Pgmq.Effectful.Effect: [PeekArchivedMessages] :: forall (a :: Type -> Type). PeekMessages -> Pgmq a (Vector ArchivedMessage)
+ Pgmq.Effectful.Effect: [PeekMessages] :: forall (a :: Type -> Type). PeekMessages -> Pgmq a (Vector Message)
+ Pgmq.Effectful.Effect: [QueueMetricsUnvalidated] :: forall (a :: Type -> Type). Text -> Pgmq a QueueMetrics
+ Pgmq.Effectful.Effect: lookupArchivedMessage :: forall (es :: [Effect]). Pgmq :> es => LookupMessage -> Eff es (Maybe ArchivedMessage)
+ Pgmq.Effectful.Effect: lookupMessage :: forall (es :: [Effect]). Pgmq :> es => LookupMessage -> Eff es (Maybe Message)
+ Pgmq.Effectful.Effect: peekArchivedMessages :: forall (es :: [Effect]). Pgmq :> es => PeekMessages -> Eff es (Vector ArchivedMessage)
+ Pgmq.Effectful.Effect: peekMessages :: forall (es :: [Effect]). Pgmq :> es => PeekMessages -> Eff es (Vector Message)
+ Pgmq.Effectful.Effect: queueMetricsUnvalidated :: forall (es :: [Effect]). Pgmq :> es => Text -> Eff es QueueMetrics
+ Pgmq.Effectful.Interpreter: isAmbiguousReply :: PgmqRuntimeError -> Bool

Files

CHANGELOG.md view
@@ -1,5 +1,62 @@ # Revision history for pgmq-effectful +## 0.7.0.0 -- 2026-10-26++### Bug Fixes++- `isTransient` now classifies a connection lost while waiting for a reply as transient+  (BUG-1). hasql reports such a loss through statement constructors rather than+  `ConnectionSessionError`, in one of two shapes, and both used to be permanent. The first+  is a `ServerError` with an empty SQLSTATE: libpq's own connection-loss result, which+  carries no fields. Every `ErrorResponse` PostgreSQL sends carries a SQLSTATE, so an empty+  code is always client-side. It appears after SIGKILL of the backend, an immediate server+  shutdown, or a TCP reset. The second is `UnexpectedRowCountStatementError 1 1 1`, hasql's+  encoding of a stray second result, which appears when the server sends a FATAL such as+  `57P01` and libpq then appends its own connection-loss result. Only that exact value is+  transient; a genuine row-count mismatch never reports an actual count inside its bounds,+  so `1 1 0`, `1 1 2`, and every other count stay permanent.+- `ScriptSessionError` now follows the same SQLSTATE rule as statement errors. Previously+  every script error was permanent, including `57P01`.+- Every other server SQLSTATE, decode, missing-type, and driver-error policy is unchanged.+  After either disconnect shape the pool keeps the dead connection, so a retry loop sees one+  more transient `ConnectionSessionError` (which evicts it) before a fresh connection+  succeeds.++### New Features++- `isAmbiguousReply :: PgmqRuntimeError -> Bool`, exported from `Pgmq.Effectful` and+  `Pgmq.Effectful.Interpreter`, answers `True` exactly when a statement was delivered but+  no server verdict came back: an empty-SQLSTATE server error in a statement or script, or+  `UnexpectedRowCountStatementError 1 1 1`. Every ambiguous reply is also transient. Retrying+  a lost send reply blindly can duplicate a message that already committed, so gate the+  retry of non-idempotent operations on `isAmbiguousReply` and reconcile first. See the+  bounded, sticky loop in the README and `docs/design/017-transient-error-classification.md`.+- Non-destructive inspection on the `Pgmq` effect (IR-1): `peekMessages`,+  `peekArchivedMessages`, `lookupMessage`, `lookupArchivedMessage`, and+  `queueMetricsUnvalidated`, re-exported from `Pgmq.Effectful` with `PeekMessages`,+  `LookupMessage`, and `ArchivedMessage`. Both interpreters run them; the traced one labels+  the four hand-written reads `pgmq.peek`, `pgmq.peek_archive`, `pgmq.lookup_message`, and+  `pgmq.lookup_archived_message` (`Internal` spans; lookups carry `messaging.message.id`)+  and keeps `pgmq.metrics` for the lenient metrics read. See+  `docs/design/019-non-destructive-inspection-reads.md`.++### Breaking Changes++- The `Pgmq` effect gained five constructors (`PeekMessages`, `PeekArchivedMessages`,+  `LookupMessage`, `LookupArchivedMessage`, `QueueMetricsUnvalidated`). An interpreter that+  matches every constructor, such as a mock in a consumer's test suite, must add five cases.++### Other Changes++Test infrastructure: create and validate owner-only (`0700`) ephemeral roots, tighten+existing owned directories, reject unsafe paths and preserve lifecycle registry contents.++- Clarify that enableNotifyInsert does not deliver on a partitioned queue's documented parent channel.++Inspection Haddocks now state the moving-view cursor contract and the need for+finite database deadlines and inspection capacity separate from workers; a row limit+does not bound scan work or payload bytes.+ ## 0.6.1.1 -- 2026-09-25  Coordinated family version bump; the effect layer is unchanged from 0.6.1.0.
pgmq-effectful.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.4 name: pgmq-effectful-version: 0.6.1.1+version: 0.7.0.0 synopsis: Effectful effects for PGMQ (PostgreSQL Message Queue) description:   Effectful effects and interpreters for pgmq-hs, a Haskell client@@ -64,8 +64,8 @@     hs-opentelemetry-api >=1.0 && <2,     hs-opentelemetry-semantic-conventions >=1.40 && <2,     http-types >=0.12 && <0.13,-    pgmq-core >=0.6 && <0.7,-    pgmq-hasql >=0.6 && <0.7,+    pgmq-core >=0.7 && <0.8,+    pgmq-hasql >=0.7 && <0.8,     text >=2.0 && <2.2,     unliftio >=0.2 && <0.3,     unordered-containers >=0.2 && <0.3,@@ -87,8 +87,10 @@    other-modules:     ClassificationSpec+    DisconnectSpec     EphemeralDb     PlainInterpreterSpec+    RootPermissionsSpec     TracedInterpreterSpec     UmbrellaExportsSpec @@ -105,7 +107,7 @@     base >=4.18 && <5,     directory,     effectful-core ^>=2.6 || ^>=2.7,-    ephemeral-pg >=0.3.1.0,+    ephemeral-pg >=0.3.1 && <0.4,     hasql ^>=1.10,     hasql-pool ^>=1.4,     hs-opentelemetry-api >=1.0 && <2,@@ -114,10 +116,10 @@     hs-opentelemetry-sdk >=1.0 && <2,     hs-opentelemetry-semantic-conventions >=1.40 && <2,     pg-migrate,-    pgmq-core >=0.6 && <0.7,-    pgmq-effectful >=0.6 && <0.7,-    pgmq-hasql >=0.6 && <0.7,-    pgmq-migration >=0.6 && <0.7,+    pgmq-core >=0.7 && <0.8,+    pgmq-effectful >=0.7 && <0.8,+    pgmq-hasql >=0.7 && <0.8,+    pgmq-migration >=0.7 && <0.8,     random ^>=1.2,     tasty ^>=1.5,     tasty-hunit ^>=0.10,
src/Pgmq/Effectful.hs view
@@ -15,6 +15,7 @@     PgmqRuntimeError (..),     fromUsageError,     isTransient,+    isAmbiguousReply,      -- ** Deprecated Error Types     PgmqError (..),@@ -106,11 +107,24 @@     queueMetrics,     allQueueMetrics, +    -- * Non-destructive Inspection++    -- | Reads that observe a queue or its archive without leasing anything.+    -- See @docs/design/019-non-destructive-inspection-reads.md@.+    peekMessages,+    peekArchivedMessages,+    lookupMessage,+    lookupArchivedMessage,+    queueMetricsUnvalidated,+    PeekMessages (..),+    LookupMessage (..),+     -- * Types     MessageBody (..),     MessageHeaders (..),     MessageId (..),     Message (..),+    ArchivedMessage (..),     Queue (..),     QueueName,     SendMessage (..),@@ -192,8 +206,13 @@     listQueues,     listTopicBindings,     listTopicBindingsForQueue,+    lookupArchivedMessage,+    lookupMessage,+    peekArchivedMessages,+    peekMessages,     pop,     queueMetrics,+    queueMetricsUnvalidated,     readGrouped,     readGroupedHead,     readGroupedHeadWithPoll,@@ -218,6 +237,7 @@   ( PgmqError (..),     PgmqRuntimeError (..),     fromUsageError,+    isAmbiguousReply,     isTransient,     runPgmq,   )@@ -252,7 +272,9 @@     BindTopic (..),     CreatePartitionedQueue (..),     EnableNotifyInsert (..),+    LookupMessage (..),     MessageQuery (..),+    PeekMessages (..),     PopMessage (..),     QueueMetrics (..),     ReadGrouped (..),@@ -270,7 +292,8 @@     VisibilityTimeoutQuery (..),   ) import Pgmq.Types-  ( Message (..),+  ( ArchivedMessage (..),+    Message (..),     MessageBody (..),     MessageHeaders (..),     MessageId (..),
src/Pgmq/Effectful/Effect.hs view
@@ -85,6 +85,13 @@     listFifoIndexQueueNames,     queueMetrics,     allQueueMetrics,++    -- * Non-destructive Inspection+    peekMessages,+    peekArchivedMessages,+    lookupMessage,+    lookupArchivedMessage,+    queueMetricsUnvalidated,   ) where @@ -108,7 +115,9 @@     BindTopic,     CreatePartitionedQueue,     EnableNotifyInsert,+    LookupMessage,     MessageQuery,+    PeekMessages,     PopMessage,     QueueMetrics,     ReadGrouped,@@ -127,7 +136,8 @@     VisibilityTimeoutQuery,   ) import Pgmq.Types-  ( Message,+  ( ArchivedMessage,+    Message,     MessageId,     NotifyInsertThrottle,     Queue,@@ -208,6 +218,12 @@   ListFifoIndexQueueNames :: Pgmq m [Text]   QueueMetrics :: QueueName -> Pgmq m QueueMetrics   AllQueueMetrics :: Pgmq m [QueueMetrics]+  -- Non-destructive inspection (no upstream function; design note 019)+  PeekMessages :: PeekMessages -> Pgmq m (Vector Message)+  PeekArchivedMessages :: PeekMessages -> Pgmq m (Vector ArchivedMessage)+  LookupMessage :: LookupMessage -> Pgmq m (Maybe Message)+  LookupArchivedMessage :: LookupMessage -> Pgmq m (Maybe ArchivedMessage)+  QueueMetricsUnvalidated :: Text -> Pgmq m QueueMetrics  type instance DispatchOf Pgmq = 'Dynamic @@ -234,6 +250,11 @@ detachArchive :: (Pgmq :> es) => QueueName -> Eff es () detachArchive = send . DetachArchive +-- | Enable best-effort insert notifications for an ordinary queue.+--+-- Normal partitioned queues receive no insert notifications on their documented+-- parent channel. See @docs/design/015-notification-delivery-contract.md@.+-- The reconciler reports partitioned notification declarations as unsupported. enableNotifyInsert :: (Pgmq :> es) => EnableNotifyInsert -> Eff es () enableNotifyInsert = send . EnableNotifyInsert @@ -432,3 +453,35 @@  allQueueMetrics :: (Pgmq :> es) => Eff es [QueueMetrics] allQueueMetrics = send AllQueueMetrics++-- Non-destructive Inspection++-- | A keyset page of a queue table without leasing anything: @vt@ and+-- @read_ct@ are untouched. Accepts any server-accepted name; pass+-- 'Pgmq.Types.queueNameToText' for a validated one. Pages by exclusive+-- @msg_id@ cursor, never @OFFSET@. A missing queue is a @PgmqSessionError@+-- carrying SQLSTATE @42P01@. Each call has its own snapshot: refresh from the+-- first page for lower IDs committed or archived after the cursor advances.+-- A row limit does not bound scan work or payload bytes. Use finite database+-- deadlines and inspection capacity separate from workers.+peekMessages :: (Pgmq :> es) => PeekMessages -> Eff es (Vector Message)+peekMessages = send . PeekMessages++-- | A keyset page of an archive table, each row with its archival timestamp.+-- Same name, paging, and error rules as 'peekMessages'.+peekArchivedMessages :: (Pgmq :> es) => PeekMessages -> Eff es (Vector ArchivedMessage)+peekArchivedMessages = send . PeekArchivedMessages++-- | One message by id without leasing it, or 'Nothing' when it is not in the+-- queue table (deleted, archived, popped, or never sent).+lookupMessage :: (Pgmq :> es) => LookupMessage -> Eff es (Maybe Message)+lookupMessage = send . LookupMessage++-- | One archived message by id, or 'Nothing' when it is not in the archive.+lookupArchivedMessage :: (Pgmq :> es) => LookupMessage -> Eff es (Maybe ArchivedMessage)+lookupArchivedMessage = send . LookupArchivedMessage++-- | 'queueMetrics' for a queue named by plain text, including names+-- 'Pgmq.Types.parseQueueName' rejects.+queueMetricsUnvalidated :: (Pgmq :> es) => Text -> Eff es QueueMetrics+queueMetricsUnvalidated = send . QueueMetricsUnvalidated
src/Pgmq/Effectful/Interpreter.hs view
@@ -6,6 +6,7 @@     PgmqRuntimeError (..),     fromUsageError,     isTransient,+    isAmbiguousReply,      -- * Legacy Error Types (deprecated; will be removed in 0.3.0)     PgmqError (..),@@ -56,14 +57,23 @@ -- -- Returns 'True' for acquisition timeouts, networking connection errors, -- uncategorized libpq connection errors, session-level connection drops,--- and server-reported statement errors whose SQLSTATE names a transient--- condition: @40001@ (serialization_failure), @40P01@ (deadlock_detected),--- @55P03@ (lock_not_available), @57P01@ (admin_shutdown), @57P02@--- (crash_shutdown), @57P03@ (cannot_connect_now), and class @53@--- (insufficient resources). All other errors (authentication failure,--- compatibility mismatches, missing types, other statement errors,--- decode\/row-count mismatches, driver bugs) are treated as permanent.+-- server-reported statement or script errors whose SQLSTATE names a+-- transient condition: @40001@ (serialization_failure), @40P01@+-- (deadlock_detected), @55P03@ (lock_not_available), @57P01@+-- (admin_shutdown), @57P02@ (crash_shutdown), @57P03@ (cannot_connect_now),+-- and class @53@ (insufficient resources); server errors in statements or+-- scripts with no SQLSTATE (client-side connection faults, see+-- 'isAmbiguousReply'); and the driver's stray-result shape+-- @UnexpectedRowCountStatementError 1 1 1@. All other errors+-- (authentication failure, compatibility mismatches, missing types, other+-- server errors, genuine decode and row-count mismatches, driver bugs) are+-- treated as permanent. --+-- A connection lost while waiting for a reply leaves the dead connection in+-- the pool, so the next attempt typically fails once more with a transient+-- 'HasqlErrors.ConnectionSessionError' (which evicts it) before a fresh+-- connection succeeds.+-- -- Note: 'HasqlErrors.OtherConnectionError' is classed as transient here -- despite hasql's documentation calling it \"not transient by default\", -- because it is the catch-all for unrecognized libpq errors and classing@@ -79,20 +89,78 @@   PgmqSessionError e -> case e of     HasqlErrors.ConnectionSessionError _ -> True     HasqlErrors.StatementSessionError _ _ _ _ _ statementError ->-      case statementError of-        HasqlErrors.ServerStatementError (HasqlErrors.ServerError code _ _ _ _) ->-          isTransientSqlState code-        _ -> False-    HasqlErrors.ScriptSessionError {} -> False+      isLostReplyStatementError statementError || isTransientStatementError statementError+    HasqlErrors.ScriptSessionError _ serverError ->+      isLostReplyServerError serverError || isTransientServerError serverError     HasqlErrors.MissingTypesSessionError _ -> False     HasqlErrors.DriverSessionError _ -> False +-- | Was the statement delivered but its reply lost, so that the caller cannot+-- tell whether PostgreSQL applied it?+--+-- 'True' for the two values hasql 1.10 produces when the connection dies while+-- it is waiting for a reply: a server error with no SQLSTATE (libpq's own+-- connection-loss result, which carries no fields) inside a statement or script+-- error, and @UnexpectedRowCountStatementError 1 1 1@ (a stray second result;+-- hasql discards the first, so the value cannot show whether that was a server+-- fatal error or a completed command). 'False' for everything else: a+-- 'HasqlErrors.ConnectionSessionError' means the send failed and nothing reached+-- the server, and every other error carries the server's verdict or the+-- driver's own decode verdict.+--+-- Every ambiguous reply is transient, so @isAmbiguousReply err@ implies+-- @isTransient err@. Gate the retry of non-idempotent operations on it: an+-- ambiguous reply to a send may have committed, so check the durable message+-- keys before sending again.+isAmbiguousReply :: PgmqRuntimeError -> Bool+isAmbiguousReply = \case+  PgmqSessionError (HasqlErrors.StatementSessionError _ _ _ _ _ statementError) ->+    isLostReplyStatementError statementError+  PgmqSessionError (HasqlErrors.ScriptSessionError _ serverError) ->+    isLostReplyServerError serverError+  _ -> False++-- | The statement-level shapes of a lost reply (see 'isAmbiguousReply').+--+-- hasql 1.10 reports a stray second result as @UnexpectedRowCountStatementError 1 1 1@+-- (@Hasql.Engine.Errors.fromRecvError@, @TooManyResultsError@ branch). A genuine+-- row-count mismatch can never carry an actual count inside its own bounds, so+-- this exact value is reserved for the driver's stray-result case, which for the+-- single-command roundtrips this library issues only happens when libpq appends+-- its own connection-loss result after a server fatal error (for example+-- @57P01@ from @pg_terminate_backend@). Every other actual count is a real+-- decoder mismatch and stays permanent.+isLostReplyStatementError :: HasqlErrors.StatementError -> Bool+isLostReplyStatementError = \case+  HasqlErrors.ServerStatementError serverError -> isLostReplyServerError serverError+  HasqlErrors.UnexpectedRowCountStatementError 1 1 1 -> True+  _ -> False++-- | Every @ErrorResponse@ PostgreSQL sends carries a SQLSTATE, so an empty code+-- means libpq manufactured the error on the client — \"server closed the+-- connection unexpectedly\" and its relatives — which hasql 1.10 surfaces with+-- every field empty.+isLostReplyServerError :: HasqlErrors.ServerError -> Bool+isLostReplyServerError (HasqlErrors.ServerError code _ _ _ _) = T.null code++-- | Server-reported statement errors worth retrying: those whose SQLSTATE is in+-- the transient whitelist.+isTransientStatementError :: HasqlErrors.StatementError -> Bool+isTransientStatementError = \case+  HasqlErrors.ServerStatementError serverError -> isTransientServerError serverError+  _ -> False++-- | Server errors (from a statement or a script) whose SQLSTATE is in the+-- transient whitelist.+isTransientServerError :: HasqlErrors.ServerError -> Bool+isTransientServerError (HasqlErrors.ServerError code _ _ _ _) = isTransientSqlState code+ -- | SQLSTATEs that indicate a transient, retry-worthy condition: @40001@ -- serialization_failure, @40P01@ deadlock_detected, @55P03@ -- lock_not_available, @57P01@ admin_shutdown, @57P02@ crash_shutdown, -- @57P03@ cannot_connect_now, and class @53@ (insufficient resources — -- 53000\/53100\/53200\/53300\/53400). These arrive as server errors inside--- 'HasqlErrors.StatementSessionError' and are precisely the errors retries+-- 'HasqlErrors.StatementSessionError' or 'HasqlErrors.ScriptSessionError' and are precisely the errors retries -- exist for. Everything else reported by the server is permanent for retry -- purposes. isTransientSqlState :: Text -> Bool@@ -179,6 +247,12 @@   ListFifoIndexQueueNames -> runSession pool Sessions.listFifoIndexQueueNames   QueueMetrics q -> runSession pool $ Sessions.queueMetrics q   AllQueueMetrics -> runSession pool Sessions.allQueueMetrics+  -- Non-destructive inspection+  PeekMessages args -> runSession pool $ Sessions.peekMessages args+  PeekArchivedMessages args -> runSession pool $ Sessions.peekArchivedMessages args+  LookupMessage args -> runSession pool $ Sessions.lookupMessage args+  LookupArchivedMessage args -> runSession pool $ Sessions.lookupArchivedMessage args+  QueueMetricsUnvalidated q -> runSession pool $ Sessions.queueMetricsUnvalidated q  -- Internal helper runSession ::
src/Pgmq/Effectful/Interpreter/Traced.hs view
@@ -41,6 +41,8 @@ -- * Messaging spans: @"publish my-queue"@, @"receive my-queue"@. -- * Lifecycle and observability spans: @"pgmq.archive my-queue"@, --   @"pgmq.set_vt my-queue"@, @"pgmq.list_queues"@ (no destination).+-- * Inspection spans: @"pgmq.peek my-queue"@, @"pgmq.lookup_message my-queue"@+--   (this library's own labels; no pgmq function backs them). -- -- == Span Kinds --@@ -356,6 +358,25 @@   AllQueueMetrics ->     withTracedOp config pool (defaultOpInfo "pgmq.metrics_all" OTel.Internal) $       Sessions.allQueueMetrics+  -- Non-destructive inspection. No pgmq function backs the four reads (they+  -- are hand-written SELECTs, see design note 019), so, like+  -- pgmq.list_fifo_indexes, they carry this library's own labels. The lenient+  -- metrics read does call pgmq.metrics and says so.+  PeekMessages args@(Types.PeekMessages qn _ _) ->+    withTracedOp config pool (queueOpText "pgmq.peek" OTel.Internal qn) $+      Sessions.peekMessages args+  PeekArchivedMessages args@(Types.PeekMessages qn _ _) ->+    withTracedOp config pool (queueOpText "pgmq.peek_archive" OTel.Internal qn) $+      Sessions.peekArchivedMessages args+  LookupMessage args@(Types.LookupMessage qn msgId) ->+    withTracedOp config pool ((queueOpText "pgmq.lookup_message" OTel.Internal qn) {opMessageId = Just msgId}) $+      Sessions.lookupMessage args+  LookupArchivedMessage args@(Types.LookupMessage qn msgId) ->+    withTracedOp config pool ((queueOpText "pgmq.lookup_archived_message" OTel.Internal qn) {opMessageId = Just msgId}) $+      Sessions.lookupArchivedMessage args+  QueueMetricsUnvalidated q ->+    withTracedOp config pool (queueOpText "pgmq.metrics" OTel.Internal q) $+      Sessions.queueMetricsUnvalidated q  -- --------------------------------------------------------------------- -- OpInfo constructors@@ -363,7 +384,13 @@  -- | 'OpInfo' for a non-messaging operation scoped to a single queue. queueOp :: Text -> OTel.SpanKind -> QueueName -> OpInfo-queueOp fn kind qn = (defaultOpInfo fn kind) {opDestination = Just (queueNameToText qn)}+queueOp fn kind qn = queueOpText fn kind (queueNameToText qn)++-- | 'OpInfo' for a non-messaging operation scoped to a queue named by plain+-- text: the lenient inspection path, which accepts names 'Pgmq.Types.parseQueueName'+-- rejects. The destination attribute carries the name exactly as given.+queueOpText :: Text -> OTel.SpanKind -> Text -> OpInfo+queueOpText fn kind name = (defaultOpInfo fn kind) {opDestination = Just name}  -- | 'OpInfo' for a @publish@ (send) on a queue. publishOp :: Text -> QueueName -> OpInfo
test/ClassificationSpec.hs view
@@ -7,6 +7,7 @@ import Hasql.Errors qualified as HasqlErrors import Pgmq.Effectful   ( PgmqRuntimeError (..),+    isAmbiguousReply,     isTransient,   ) import Test.Tasty (TestTree, testGroup)@@ -29,6 +30,69 @@         (HasqlErrors.ServerStatementError (HasqlErrors.ServerError code "boom" Nothing Nothing Nothing))     ) +-- | A server error reported for a 'HasqlErrors.ScriptSessionError' with the+-- given SQLSTATE.+scriptError :: Text -> PgmqRuntimeError+scriptError code =+  PgmqSessionError+    ( HasqlErrors.ScriptSessionError+        "checkpoint"+        (HasqlErrors.ServerError code "boom" Nothing Nothing Nothing)+    )++-- | The value hasql 1.10 surfaces when libpq's own connection-loss result is+-- decoded: a server error with every field empty.+lostReplyStatementError :: PgmqRuntimeError+lostReplyStatementError =+  PgmqSessionError+    ( HasqlErrors.StatementSessionError+        1+        0+        "select 1"+        []+        True+        (HasqlErrors.ServerStatementError (HasqlErrors.ServerError "" "" Nothing Nothing Nothing))+    )++-- | A row-count statement error with the given bounds and actual count.+rowCountError :: Int -> Int -> Int -> PgmqRuntimeError+rowCountError lo hi actual =+  PgmqSessionError+    ( HasqlErrors.StatementSessionError+        1+        0+        "select 1"+        []+        True+        (HasqlErrors.UnexpectedRowCountStatementError lo hi actual)+    )++-- | Connection-level samples (acquisition and connect failures).+connectionSamples :: [(String, PgmqRuntimeError)]+connectionSamples =+  [ ("networking connection error", PgmqConnectionError (HasqlErrors.NetworkingConnectionError "refused")),+    ("authentication error", PgmqConnectionError (HasqlErrors.AuthenticationConnectionError "bad password")),+    ("compatibility error", PgmqConnectionError (HasqlErrors.CompatibilityConnectionError "version mismatch")),+    ("other connection error", PgmqConnectionError (HasqlErrors.OtherConnectionError "libpq says no"))+  ]++-- | Every sample error this module constructs, for whole-table properties.+allSamples :: [(String, PgmqRuntimeError)]+allSamples =+  [("acquisition timeout", PgmqAcquisitionTimeout)]+    <> connectionSamples+    <> [ ("connection-drop session error", PgmqSessionError (HasqlErrors.ConnectionSessionError "dropped")),+         ("driver session error", PgmqSessionError (HasqlErrors.DriverSessionError "bug")),+         ("missing-types session error", PgmqSessionError (HasqlErrors.MissingTypesSessionError HashSet.empty)),+         ("empty-SQLSTATE statement error", lostReplyStatementError),+         ("empty-SQLSTATE script error", scriptError ""),+         ("row count 1 1 0", rowCountError 1 1 0),+         ("row count 1 1 1", rowCountError 1 1 1),+         ("row count 1 1 2", rowCountError 1 1 2)+       ]+    <> [("statement " <> show code, serverStatementError code) | (code, _) <- transientStates <> permanentStates]+    <> [("script " <> show code, scriptError code) | (code, _) <- transientStates <> permanentStates]+ -- | SQLSTATEs that retries exist for: they arrive as server errors inside -- 'StatementSessionError' and must classify transient. transientStates :: [(Text, String)]@@ -115,20 +179,58 @@                | (code, name) <- permanentStates                ]         ),-      testCase "row-count decode failure is not transient" $-        assertBool-          "expected permanent"-          ( not $-              isTransient-                ( PgmqSessionError-                    ( HasqlErrors.StatementSessionError-                        1-                        0-                        "select 1"-                        []-                        True-                        (HasqlErrors.UnexpectedRowCountStatementError 1 1 0)-                    )-                )-          )+      testCase "row-count decode failure (1 1 0) is not transient" $+        assertBool "expected permanent" (not (isTransient (rowCountError 1 1 0))),+      testCase "row-count decode failure (1 1 2) is not transient" $+        assertBool "expected permanent" (not (isTransient (rowCountError 1 1 2))),+      testGroup+        "lost replies (BUG-1)"+        [ testCase "statement error with empty SQLSTATE is transient" $+            assertBool "expected transient" (isTransient lostReplyStatementError),+          testCase "script error with empty SQLSTATE is transient" $+            assertBool "expected transient" (isTransient (scriptError "")),+          testCase "stray-result shape (1 1 1) is transient" $+            assertBool "expected transient" (isTransient (rowCountError 1 1 1))+        ],+      testGroup+        "script errors follow the SQLSTATE rule"+        [ testCase "script 57P01 is transient" $+            assertBool "expected transient" (isTransient (scriptError "57P01")),+          testCase "script 23505 is permanent" $+            assertBool "expected permanent" (not (isTransient (scriptError "23505")))+        ],+      testGroup+        "isAmbiguousReply"+        ( [ testCase "empty-SQLSTATE statement error is ambiguous" $+              assertBool "expected ambiguous" (isAmbiguousReply lostReplyStatementError),+            testCase "empty-SQLSTATE script error is ambiguous" $+              assertBool "expected ambiguous" (isAmbiguousReply (scriptError "")),+            testCase "stray-result shape (1 1 1) is ambiguous" $+              assertBool "expected ambiguous" (isAmbiguousReply (rowCountError 1 1 1))+          ]+            <> [ testCase (name <> " is not ambiguous") $+                   assertBool "expected unambiguous" (not (isAmbiguousReply err))+               | (name, err) <-+                   [ ("statement \"57P01\"", serverStatementError "57P01"),+                     ("statement \"23505\"", serverStatementError "23505"),+                     ("script \"57P01\"", scriptError "57P01"),+                     ("row count 1 1 0", rowCountError 1 1 0),+                     ("row count 1 1 2", rowCountError 1 1 2),+                     ("connection-drop session error", PgmqSessionError (HasqlErrors.ConnectionSessionError "dropped")),+                     ("acquisition timeout", PgmqAcquisitionTimeout),+                     ("driver session error", PgmqSessionError (HasqlErrors.DriverSessionError "bug")),+                     ("missing-types session error", PgmqSessionError (HasqlErrors.MissingTypesSessionError HashSet.empty))+                   ]+                     <> connectionSamples+               ]+            <> [ testCase "every ambiguous reply is transient" $+                   mapM_+                     ( \(name, err) ->+                         assertBool+                           (name <> " is ambiguous but not transient")+                           (not (isAmbiguousReply err) || isTransient err)+                     )+                     allSamples+               ]+        )     ]
+ test/DisconnectSpec.hs view
@@ -0,0 +1,324 @@+{-# LANGUAGE DuplicateRecordFields #-}+{-# LANGUAGE OverloadedStrings #-}++-- | BUG-1: a connection lost while a statement is in flight must classify as+-- transient, whichever hasql constructor it arrives in.+--+-- Drives three real faults against a dedicated PostgreSQL (never the+-- suite-shared one) while a 'readWithPoll' is blocked server-side, records+-- what surfaced, and asserts that 'isTransient' says retry and that the same+-- pool completes a later send.+--+-- All work happens inside one 'withResource' acquisition; the test cases are+-- pure assertions over the recorded observations, because tasty runs cases+-- concurrently and a sequence of faults must not be split across them.+module DisconnectSpec (tests) where++import Control.Concurrent (forkIO, threadDelay)+import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar)+import Control.Exception (SomeException, bracket, try)+import Data.Aeson qualified as Aeson+import Data.IORef (IORef, newIORef, readIORef, writeIORef)+import Data.Int (Int32, Int64)+import Data.Text qualified as T+import Data.Word (Word32)+import Effectful (Eff, IOE, runEff)+import Effectful.Error.Static (Error, runError)+import EphemeralDb (ephemeralConfig, installPgmqNative)+import EphemeralPg qualified as Pg++-- 'shutdownMode' names a field of both Pg.Config and Pg.Database, so the record+-- update below needs the selector from the module that defines only Database.+import EphemeralPg.Database qualified as PgDb+import Hasql.Decoders qualified as D+import Hasql.Encoders qualified as E+import Hasql.Errors qualified as HasqlErrors+import Hasql.Pool qualified as Pool+import Hasql.Pool.Config qualified as PoolConfig+import Hasql.Session (Session)+import Hasql.Session qualified as Session+import Hasql.Statement (Statement, preparable)+import Pgmq.Effectful+  ( MessageBody (..),+    Pgmq,+    PgmqRuntimeError (..),+    QueueName,+    ReadWithPollMessage (..),+    SendMessage (..),+    createQueue,+    isAmbiguousReply,+    isTransient,+    parseQueueName,+    readWithPoll,+    runPgmq,+    sendMessage,+  )+import System.Posix.Signals (sigKILL, signalProcess)+import System.Random (randomRIO)+import Test.Tasty (TestTree, testGroup, withResource)+import Test.Tasty.HUnit (assertBool, assertFailure, testCase)++-- | What one fault produced.+data FaultObservation = FaultObservation+  { -- | The error the in-flight poll surfaced; Nothing means it completed.+    obsError :: !(Maybe PgmqRuntimeError),+    -- | Attempts the same pool needed to complete a send afterwards.+    obsRecoveryAttempts :: !Int,+    -- | Errors seen on the way to recovery, in order.+    obsRecoveryErrors :: ![PgmqRuntimeError],+    -- | Whether the send eventually succeeded within the attempt budget.+    obsRecovered :: !Bool+  }++data DisconnectObservations = DisconnectObservations+  { obsTerminate :: !FaultObservation,+    obsKill :: !FaultObservation,+    obsRestart :: !FaultObservation+  }++tests :: TestTree+tests =+  withResource runDisconnectCycle (const (pure ())) $ \getObs ->+    testGroup+      "DisconnectSpec (BUG-1)"+      [ faultCases "backend termination" (obsTerminate <$> getObs),+        faultCases "backend SIGKILL" (obsKill <$> getObs),+        faultCases "immediate restart" (obsRestart <$> getObs),+        testCase "SIGKILL reply is ambiguous" $ do+          obs <- obsKill <$> getObs+          case obsError obs of+            Nothing -> pure ()+            Just err -> assertBool ("expected an ambiguous reply, got " <> show err) (isAmbiguousReply err)+      ]++faultCases :: String -> IO FaultObservation -> TestTree+faultCases label getObs =+  testGroup+    label+    [ testCase "surfaces an error" $ do+        obs <- getObs+        case obsError obs of+          Nothing -> assertFailure "the in-flight poll completed; the fault did not land"+          Just _ -> pure (),+      testCase "error is a disconnect shape" $ do+        obs <- getObs+        case obsError obs of+          Nothing -> pure ()+          Just err -> assertBool ("unexpected shape: " <> show err) (isDisconnectShape err),+      testCase "error is transient" $ do+        obs <- getObs+        case obsError obs of+          Nothing -> pure ()+          Just err -> assertBool ("expected transient, got " <> show err) (isTransient err),+      testCase "ambiguous replies are transient" $ do+        obs <- getObs+        case obsError obs of+          Nothing -> pure ()+          Just err ->+            assertBool+              ("ambiguous but not transient: " <> show err)+              (not (isAmbiguousReply err) || isTransient err),+      testCase "same pool recovers" $ do+        obs <- getObs+        assertBool+          ( "pool did not recover within "+              <> show (obsRecoveryAttempts obs)+              <> " attempts; errors seen: "+              <> show (obsRecoveryErrors obs)+          )+          (obsRecovered obs)+    ]++-- | The values a lost connection is known to arrive in (see design note 017).+isDisconnectShape :: PgmqRuntimeError -> Bool+isDisconnectShape = \case+  PgmqSessionError (HasqlErrors.ConnectionSessionError _) -> True+  PgmqSessionError (HasqlErrors.StatementSessionError _ _ _ _ _ statementError) ->+    case statementError of+      HasqlErrors.ServerStatementError (HasqlErrors.ServerError code _ _ _ _) ->+        code `elem` ["", "57P01", "57P02"]+      HasqlErrors.UnexpectedRowCountStatementError 1 1 1 -> True+      _ -> False+  _ -> False++-- The fault cycle -------------------------------------------------------------++-- | Start a dedicated PostgreSQL, run the three faults in order against one+-- size-1 pool, and record what each produced. Every resource is released+-- before this returns.+runDisconnectCycle :: IO DisconnectObservations+runDisconnectCycle = do+  db0 <- startOrFail+  ref <- newIORef db0+  bracket (pure ref) (\r -> readIORef r >>= Pg.stop) $ \dbRef -> do+    installPgmqNative (Pg.connectionSettings db0)+    -- The poll queue stays empty so every poll blocks server-side; recovery+    -- sends go to a separate queue so they never satisfy a later poll.+    pollQueue <- genQueueName "disconnect_poll_"+    sendQueue <- genQueueName "disconnect_send_"+    bracket (acquirePool db0) Pool.release $ \pool -> do+      runOrFail pool (createQueue pollQueue >> createQueue sendQueue)++      terminate <- faultRound "backend termination" dbRef pool pollQueue sendQueue $ \pid -> do+        db <- readIORef dbRef+        bracket (acquirePool db) Pool.release $ \control ->+          () <$ session control (Session.statement pid terminateBackend)++      kill <- faultRound "backend SIGKILL" dbRef pool pollQueue sendQueue $ \pid ->+        signalProcess sigKILL (fromIntegral pid)++      restart <- faultRound "immediate restart" dbRef pool pollQueue sendQueue $ \_pid -> do+        db <- readIORef dbRef+        db' <- crashAndRecover db+        writeIORef dbRef db'++      pure DisconnectObservations {obsTerminate = terminate, obsKill = kill, obsRestart = restart}++-- | Read the pool connection's backend pid, block it in 'readWithPoll', inject+-- the fault, collect what the poll surfaced, and then measure how many sends+-- the same pool needs to succeed again.+faultRound ::+  String ->+  IORef Pg.Database ->+  Pool.Pool ->+  QueueName ->+  QueueName ->+  (Int32 -> IO ()) ->+  IO FaultObservation+faultRound label dbRef pool pollQueue sendQueue inject = do+  -- ephemeral-pg runs PostgreSQL with fsync and synchronous_commit off, so a+  -- crash would otherwise discard the queues still sitting in the WAL buffers.+  session pool (Session.script "checkpoint")+  pid <- session pool (Session.statement () backendPid)+  outcomeVar <- newEmptyMVar+  _ <- forkIO $ do+    result <- try @SomeException (runPoll pool pollQueue)+    putMVar outcomeVar result+  db <- readIORef dbRef+  awaitPolling db pid 200+  inject pid+  outcome <- takeMVar outcomeVar+  err <- case outcome of+    Left exc -> assertFailure (label <> ": poll thread threw " <> show exc)+    Right (Left runtimeErr) -> pure (Just runtimeErr)+    Right (Right ()) -> pure Nothing+  (attempts, errors, recovered) <- recover pool sendQueue 100+  pure+    FaultObservation+      { obsError = err,+        obsRecoveryAttempts = attempts,+        obsRecoveryErrors = errors,+        obsRecovered = recovered+      }++runPoll :: Pool.Pool -> QueueName -> IO (Either PgmqRuntimeError ())+runPoll pool queue =+  runPgmqIO pool . (() <$) . readWithPoll $+    ReadWithPollMessage+      { queueName = queue,+        delay = 30,+        batchSize = Just 1,+        maxPollSeconds = 15,+        pollIntervalMs = 100,+        conditional = Nothing+      }++-- | Wait until the server shows the given backend busy in @read_with_poll@.+-- The attacked pool's only connection is busy, so this opens its own+-- short-lived control pool each time (the SIGKILL round restarts every+-- backend, so nothing is shared across rounds).+awaitPolling :: Pg.Database -> Int32 -> Int -> IO ()+awaitPolling db pid budget =+  bracket (acquirePool db) Pool.release $ \control -> go control budget+  where+    go _ 0 = assertFailure ("backend " <> show pid <> " never showed read_with_poll as active")+    go control n = do+      count <- Pool.use control (Session.statement pid activePollCount)+      case count of+        Right 1 -> pure ()+        _ -> threadDelay 50_000 >> go control (n - 1)++-- | Send one message through the attacked pool, retrying while the error is+-- transient, with a 100 ms pause, up to the attempt budget. Returns the+-- attempts used, every error seen in order, and whether a send succeeded. A+-- permanent error stops the loop.+recover :: Pool.Pool -> QueueName -> Int -> IO (Int, [PgmqRuntimeError], Bool)+recover pool queue budget = go 1 []+  where+    go attempt seen = do+      result <-+        runPgmqIO pool $+          () <$ sendMessage SendMessage {queueName = queue, messageBody = MessageBody (Aeson.String "probe"), delay = Nothing}+      case result of+        Right () -> pure (attempt, reverse seen, True)+        Left err+          | attempt >= budget || not (isTransient err) -> pure (attempt, reverse (err : seen), False)+          | otherwise -> threadDelay 100_000 >> go (attempt + 1) (err : seen)++-- | Stop PostgreSQL with SIGQUIT and start it again on the same data directory+-- and port. Retries once, then fails loudly.+crashAndRecover :: Pg.Database -> IO Pg.Database+crashAndRecover db = do+  let crashing = db {PgDb.shutdownMode = Pg.ShutdownImmediate}+  first <- Pg.restart crashing+  case first of+    Right db' -> pure db'+    Left _ -> do+      second <- Pg.restart crashing+      case second of+        Right db' -> pure db'+        Left err -> assertFailure $ "could not restart PostgreSQL after crash: " <> show err++-- Database plumbing -----------------------------------------------------------++startOrFail :: IO Pg.Database+startOrFail = do+  config <- ephemeralConfig+  result <- Pg.startCached config Pg.defaultCacheConfig+  case result of+    Left err -> assertFailure $ "could not start a dedicated PostgreSQL: " <> show err+    Right db -> pure db++-- | Size 1 is essential: it guarantees the backend pid read before the fault+-- belongs to the connection the poll uses.+acquirePool :: Pg.Database -> IO Pool.Pool+acquirePool db =+  Pool.acquire $+    PoolConfig.settings+      [ PoolConfig.size 1,+        PoolConfig.staticConnectionSettings (Pg.connectionSettings db)+      ]++runPgmqIO :: Pool.Pool -> Eff '[Pgmq, Error PgmqRuntimeError, IOE] a -> IO (Either PgmqRuntimeError a)+runPgmqIO pool action = either (Left . snd) Right <$> runEff (runError @PgmqRuntimeError (runPgmq pool action))++runOrFail :: Pool.Pool -> Eff '[Pgmq, Error PgmqRuntimeError, IOE] a -> IO a+runOrFail pool action =+  runPgmqIO pool action >>= either (\err -> assertFailure ("pgmq operation failed: " <> show err)) pure++session :: Pool.Pool -> Session a -> IO a+session pool s = do+  result <- Pool.use pool s+  case result of+    Left err -> assertFailure $ "Session failed: " <> show err+    Right a -> pure a++genQueueName :: T.Text -> IO QueueName+genQueueName prefix = do+  suffix <- randomRIO (10000 :: Word32, 99999)+  case parseQueueName (prefix <> T.pack (show suffix)) of+    Left err -> error $ "Failed to generate queue name: " <> show err+    Right qn -> pure qn++backendPid :: Statement () Int32+backendPid = preparable "select pg_backend_pid()" E.noParams (D.singleRow (D.column (D.nonNullable D.int4)))++terminateBackend :: Statement Int32 Bool+terminateBackend = preparable "select pg_terminate_backend($1)" (E.param (E.nonNullable E.int4)) (D.singleRow (D.column (D.nonNullable D.bool)))++activePollCount :: Statement Int32 Int64+activePollCount =+  preparable+    "select count(*)::int8 from pg_stat_activity where pid = $1 and state = 'active' and query like '%read_with_poll%'"+    (E.param (E.nonNullable E.int4))+    (D.singleRow (D.column (D.nonNullable D.int8)))
test/EphemeralDb.hs view
@@ -9,11 +9,15 @@ module EphemeralDb   ( withPgmqDb,     withPgmqPool,+    ephemeralConfig,+    ensureOwnerOnlyRoot,+    installPgmqNative,     StartError,   ) where -import Control.Monad (filterM, when)+import Control.Monad (filterM, unless, when)+import Data.Bits ((.&.)) import Data.List.NonEmpty (NonEmpty (..)) import Data.Monoid (Last (..)) import Data.Text.IO qualified as TextIO@@ -30,12 +34,16 @@     defaultConfig,     withCachedConfig,   )+import Hasql.Connection.Settings qualified as ConnectionSettings import Hasql.Pool qualified as Pool import Hasql.Pool.Config qualified as PoolConfig import Hasql.Session qualified as Session import Pgmq.Migration qualified as Migration-import System.Directory (createDirectoryIfMissing, doesFileExist)+import System.Directory (doesFileExist) import System.Environment (lookupEnv)+import System.IO.Error (catchIOError, isAlreadyExistsError)+import System.Posix.Directory qualified as Posix+import System.Posix.Files (deviceID, fileID, fileMode, fileOwner, getSymbolicLinkStatus, isDirectory, setFileMode) import System.Posix.User (getEffectiveUserID)  -- | Root directory for ephemeral PostgreSQL clusters.@@ -53,11 +61,31 @@   uid <- getEffectiveUserID   pure ("/tmp/ephpg-pgmq-hs-" <> show uid) +-- | Establish a real effective-uid-owned root without deleting its contents.+ensureOwnerOnlyRoot :: FilePath -> IO ()+ensureOwnerOnlyRoot root = do+  catchIOError (Posix.createDirectory root 0o700) $ \err ->+    unless (isAlreadyExistsError err) (ioError err)+  uid <- getEffectiveUserID+  before <- getSymbolicLinkStatus root+  unless (isDirectory before && fileOwner before == uid) $+    ioError (userError ("Unsafe ephemeral PostgreSQL root: " <> root))+  setFileMode root 0o700+  after <- getSymbolicLinkStatus root+  unless+    ( isDirectory after+        && fileOwner after == uid+        && deviceID before == deviceID after+        && fileID before == fileID after+        && fileMode after .&. 0o7777 == 0o700+    )+    (ioError (userError ("Ephemeral PostgreSQL root changed or is not owner-only: " <> root)))+ -- | Cached-startup configuration pinned to 'ephemeralRoot'. ephemeralConfig :: IO Config ephemeralConfig = do   root <- ephemeralRoot-  createDirectoryIfMissing True root+  ensureOwnerOnlyRoot root   pure defaultConfig {temporaryRoot = Last (Just root)}  -- | Run an action with a temporary PostgreSQL database that has the@@ -82,8 +110,8 @@             [] -> error "Missing packaged PGMQ 1.12.0 test fixture"           sql <- TextIO.readFile path           Pool.use pool (Session.script sql) >>= either (error . show) pure-        Nothing -> installNative connSettings-        Just "1.13.0" -> installNative connSettings+        Nothing -> installPgmqNative connSettings+        Just "1.13.0" -> installPgmqNative connSettings         Just invalid -> error ("Invalid PGMQ_TEST_SCHEMA_VERSION: " <> invalid)       required <- (== Just "1") <$> lookupEnv "PGMQ_REQUIRE_PARTMAN"       -- Required runs fail for absent or unusable pg_partman, not just missing metadata.@@ -92,12 +120,15 @@         Left err -> when required (error ("PGMQ_REQUIRE_PARTMAN=1: " <> show err))         Right () -> pure ()       action pool-  where-    installNative connSettings = do-      component <- either (error . ("Invalid PGMQ migration component: " <>) . show) pure Migration.pgmqMigrations-      plan <- either (error . ("Invalid PGMQ migration plan: " <>) . show) pure (migrationPlan (component :| []))-      installResult <- runMigrationPlan defaultRunOptions connSettings plan-      either (error . ("Migration failed: " <>) . show) (const (pure ())) installResult++-- | Apply the native pgmq migration ledger to the database at the given+-- connection settings, failing loudly on any error.+installPgmqNative :: ConnectionSettings.Settings -> IO ()+installPgmqNative connSettings = do+  component <- either (error . ("Invalid PGMQ migration component: " <>) . show) pure Migration.pgmqMigrations+  plan <- either (error . ("Invalid PGMQ migration plan: " <>) . show) pure (migrationPlan (component :| []))+  installResult <- runMigrationPlan defaultRunOptions connSettings plan+  either (error . ("Migration failed: " <>) . show) (const (pure ())) installResult  -- | Alias for 'withPgmqDb' kept for parallelism with pgmq-hasql. withPgmqPool :: (Pool.Pool -> IO a) -> IO (Either StartError a)
test/Main.hs view
@@ -3,8 +3,10 @@ module Main (main) where  import ClassificationSpec qualified-import EphemeralDb (withPgmqPool)+import DisconnectSpec qualified+import EphemeralDb (ensureOwnerOnlyRoot, withPgmqPool) import PlainInterpreterSpec qualified+import RootPermissionsSpec qualified import Test.Tasty (defaultMain, testGroup) import TracedInterpreterSpec qualified import UmbrellaExportsSpec ()@@ -15,9 +17,11 @@     let tree =           testGroup             "pgmq-effectful"-            [ ClassificationSpec.tests,+            [ RootPermissionsSpec.tests ensureOwnerOnlyRoot,+              ClassificationSpec.tests,               PlainInterpreterSpec.tests pool,-              TracedInterpreterSpec.tests pool+              TracedInterpreterSpec.tests pool,+              DisconnectSpec.tests             ]     defaultMain tree   case result of
+ test/RootPermissionsSpec.hs view
@@ -0,0 +1,63 @@+-- | Check each suite's local root helper using private disposable fixtures.+module RootPermissionsSpec (tests) where++import Control.Exception (IOException, bracket, try)+import Data.Bits ((.&.))+import System.Directory (getTemporaryDirectory, removePathForcibly)+import System.Posix.Directory qualified as Posix+import System.Posix.Files (createSymbolicLink, fileMode, getSymbolicLinkStatus, isDirectory, setFileMode)+import System.Posix.Temp (mkdtemp)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=))++tests :: (FilePath -> IO ()) -> TestTree+tests ensure =+  testGroup+    "RootPermissionsSpec"+    [ testCase "missing root is a real 0700 directory" $ private $ \parent -> do+        let root = parent <> "/root"+        ensure root+        status <- getSymbolicLinkStatus root+        assertBool "real directory" (isDirectory status)+        fileMode status .&. 0o7777 @?= 0o700,+      testCase "owned 0755 directory is tightened without losing contents" $ private $ \parent -> do+        let root = parent <> "/root"+        Posix.createDirectory root 0o755+        setFileMode root 0o755+        writeFile (root <> "/sentinel") "preserve"+        ensure root+        mode root >>= (@?= 0o700)+        readFile (root <> "/sentinel") >>= (@?= "preserve"),+      testCase "repeated call preserves contents and permissions" $ private $ \parent -> do+        let root = parent <> "/root"+        ensure root+        writeFile (root <> "/sentinel") "preserve"+        ensure root+        mode root >>= (@?= 0o700)+        readFile (root <> "/sentinel") >>= (@?= "preserve"),+      testCase "regular file is rejected unchanged" $ private $ \parent -> do+        let root = parent <> "/root"+        writeFile root "preserve"+        setFileMode root 0o644+        rejected (ensure root)+        mode root >>= (@?= 0o644)+        readFile root >>= (@?= "preserve"),+      testCase "symlink is rejected without chmod of its target" $ private $ \parent -> do+        let root = parent <> "/root"+            target = parent <> "/target"+        Posix.createDirectory target 0o755+        setFileMode target 0o755+        writeFile (target <> "/sentinel") "preserve"+        createSymbolicLink target root+        rejected (ensure root)+        mode target >>= (@?= 0o755)+        readFile (target <> "/sentinel") >>= (@?= "preserve")+    ]+  where+    mode path = (.&. 0o7777) . fileMode <$> getSymbolicLinkStatus path+    rejected action = do+      result <- try action :: IO (Either IOException ())+      case result of+        Left _ -> pure ()+        Right () -> assertFailure "unsafe root accepted"+    private action = bracket (getTemporaryDirectory >>= \tmp -> mkdtemp (tmp <> "/pgmq-root-test-")) removePathForcibly action
test/TracedInterpreterSpec.hs view
@@ -521,7 +521,57 @@                        Left (_, PgmqSessionError err) -> assertBool "server rejection is retained" (T.isInfixOf (if stock then "42883" else "premake must be at least 1") (T.pack (show err)))                        other -> assertFailure ("expected session error, got " <> show other)                  )-                 [0, -1]+                 [0, -1],+           testCase "non-destructive inspection reads" $+             withSemconvOptIn "messaging/dup,database/dup" $+               isolated $ \pool -> do+                 (tracer, _, spansRef) <- setupTracer+                 let run :: Eff '[Eff.Pgmq, Error PgmqRuntimeError, IOE] a -> IO a+                     run action = assertRight =<< runEff (runError @PgmqRuntimeError ((if traced then runPgmqTraced pool tracer else runPgmq pool) action))+                 queue <- mkUniqueQueue "inspect"+                 let q = queueNameToText queue+                 run (Eff.createQueue queue)+                 ids <- mapM (\i -> run (Eff.sendMessage (SendMessage queue (MessageBody (object ["i" .= (i :: Int)])) Nothing))) [1 .. 3]+                 (archivedId, liveId) <- case ids of+                   [a, b, _] -> pure (a, b)+                   other -> assertFailure ("expected three ids, got " <> show other)+                 archivedOk <- run (Eff.archiveMessage (MessageQuery queue archivedId))+                 assertBool "archive succeeded" archivedOk+                 page <- run (Eff.peekMessages (Types.PeekMessages q Nothing 10))+                 assertEqual "two live messages" (drop 1 ids) (map Pgmq.messageId (V.toList page))+                 archived <- run (Eff.peekArchivedMessages (Types.PeekMessages q Nothing 10))+                 assertEqual "one archived message" [archivedId] (map (Pgmq.messageId . Pgmq.archivedMessage) (V.toList archived))+                 found <- run (Eff.lookupMessage (Types.LookupMessage q liveId))+                 assertEqual "lookup finds a live message" (Just liveId) (fmap Pgmq.messageId found)+                 missing <- run (Eff.lookupArchivedMessage (Types.LookupMessage q liveId))+                 assertEqual "a live message is not in the archive" Nothing (fmap (Pgmq.messageId . Pgmq.archivedMessage) missing)+                 metrics <- run (Eff.queueMetricsUnvalidated q)+                 assertEqual "lenient metrics" 2 (Types.queueLength metrics)+                 -- The peeks leased nothing: a real read now returns both, and+                 -- read_ct is one because only this read incremented it.+                 leased <- run (Eff.readMessage (ReadMessage queue 30 (Just 10) Nothing))+                 assertEqual "both messages still available" (drop 1 ids) (map Pgmq.messageId (V.toList leased))+                 assertEqual "read_ct counts only the real read" [1, 1] (map Pgmq.readCount (V.toList leased))+                 when traced $ do+                   spans <- readIORef spansRef+                   peeks <- spansWithFirstWord "pgmq.peek" spans+                   peekSpan <- singleSpan peeks "peek"+                   spanName peekSpan >>= assertEqual "peek span name" ("pgmq.peek " <> q)+                   case OTel.spanKind peekSpan of+                     OTel.Internal -> pure ()+                     other -> assertFailure ("expected Internal: " <> show other)+                   assertAttrText peekSpan "db.operation" "pgmq.peek"+                   assertAttrText peekSpan "db.operation.name" "pgmq.peek"+                   assertAttrText peekSpan "messaging.destination.name" q+                   assertNoAttr peekSpan "messaging.operation"+                   archivePeeks <- spansWithFirstWord "pgmq.peek_archive" spans+                   _ <- singleSpan archivePeeks "archive peek"+                   lookups <- spansWithFirstWord "pgmq.lookup_message" spans+                   lookupSpan <- singleSpan lookups "lookup"+                   assertAttrText lookupSpan "messaging.message.id" (T.pack (show (Pgmq.unMessageId liveId)))+                   archivedLookups <- spansWithFirstWord "pgmq.lookup_archived_message" spans+                   _ <- singleSpan archivedLookups "archived lookup"+                   pure ()          ]  session :: Pool.Pool -> Session.Session a -> IO a
test/UmbrellaExportsSpec.hs view
@@ -4,9 +4,10 @@ {-# LANGUAGE TypeOperators #-}  -- Compile witnesses: this module intentionally uses only the public umbrella.-module UmbrellaExportsSpec (grouped, polling, creation, arguments, metric) where+module UmbrellaExportsSpec (grouped, polling, creation, arguments, metric, classifiers, inspection, lenientMetrics, inspectionArguments) where  import Data.Int (Int32, Int64)+import Data.Text (Text) import Data.Vector (Vector) import Effectful (Eff, (:>)) import Pgmq.Effectful@@ -25,3 +26,15 @@  metric :: QueueMetrics -> Maybe Int64 metric = defaultPartitionLength++classifiers :: PgmqRuntimeError -> (Bool, Bool)+classifiers err = (isTransient err, isAmbiguousReply err)++inspection :: (Pgmq :> es) => PeekMessages -> LookupMessage -> (Eff es (Vector Message), Eff es (Vector ArchivedMessage), Eff es (Maybe Message), Eff es (Maybe ArchivedMessage))+inspection page one = (peekMessages page, peekArchivedMessages page, lookupMessage one, lookupArchivedMessage one)++lenientMetrics :: (Pgmq :> es) => Text -> Eff es QueueMetrics+lenientMetrics = queueMetricsUnvalidated++inspectionArguments :: Text -> (PeekMessages, LookupMessage)+inspectionArguments q = (PeekMessages q Nothing 50, LookupMessage q (MessageId 1))