pgmq-config 0.6.1.1 → 0.7.0.0
raw patch · 37 files changed
+584/−99 lines, 37 filesdep +tasty-goldendep ~ephemeral-pgdep ~pgmq-configdep ~pgmq-corePVP ok
version bump matches the API change (PVP)
Dependencies added: tasty-golden
Dependency ranges changed: ephemeral-pg, pgmq-config, pgmq-core, pgmq-effectful, pgmq-hasql, pgmq-migration
API changes (from Hackage documentation)
+ Pgmq.Config: DetectedQueueNameCollision :: !QueueName -> !NonEmpty Text -> ReconcileAction
+ Pgmq.Config: UnsupportedNotifyOnPartitionedQueue :: !QueueName -> ReconcileAction
+ Pgmq.Config.Types: DetectedQueueNameCollision :: !QueueName -> !NonEmpty Text -> ReconcileAction
+ Pgmq.Config.Types: UnsupportedNotifyOnPartitionedQueue :: !QueueName -> ReconcileAction
+ Pgmq.Config.Types: instance Data.Aeson.Types.ToJSON.ToJSON Pgmq.Config.Types.ObservedQueueType
+ Pgmq.Config.Types: instance Data.Aeson.Types.ToJSON.ToJSON Pgmq.Config.Types.PartitionConfig
+ Pgmq.Config.Types: instance Data.Aeson.Types.ToJSON.ToJSON Pgmq.Config.Types.QueueType
+ Pgmq.Config.Types: instance Data.Aeson.Types.ToJSON.ToJSON Pgmq.Config.Types.ReconcileAction
Files
- CHANGELOG.md +29/−0
- pgmq-config.cabal +18/−11
- src/Pgmq/Config.hs +19/−3
- src/Pgmq/Config/Reconcile.hs +80/−68
- src/Pgmq/Config/Types.hs +119/−5
- test/CollisionSpec.hs +80/−0
- test/ConfigSpec.hs +2/−0
- test/EphemeralDb.hs +28/−3
- test/JsonGoldenSpec.hs +62/−0
- test/Main.hs +9/−2
- test/NotifyCrashSpec.hs +13/−6
- test/PartitionSpec.hs +38/−1
- test/RootPermissionsSpec.hs +63/−0
- test/golden/bound_topic.json +1/−0
- test/golden/created_fifo_index.json +1/−0
- test/golden/created_queue_partitioned.json +1/−0
- test/golden/created_queue_partitioned_default_premake.json +1/−0
- test/golden/created_queue_standard.json +1/−0
- test/golden/created_queue_unlogged.json +1/−0
- test/golden/detected_queue_name_collision.json +1/−0
- test/golden/detected_queue_type_drift.json +1/−0
- test/golden/enabled_notify.json +1/−0
- test/golden/enabled_notify_default.json +1/−0
- test/golden/observed_partitioned.json +1/−0
- test/golden/observed_standard.json +1/−0
- test/golden/observed_unlogged.json +1/−0
- test/golden/partition_config.json +1/−0
- test/golden/partition_config_default.json +1/−0
- test/golden/queue_type_partitioned.json +1/−0
- test/golden/queue_type_standard.json +1/−0
- test/golden/queue_type_unlogged.json +1/−0
- test/golden/skipped_fifo_index.json +1/−0
- test/golden/skipped_notify.json +1/−0
- test/golden/skipped_queue.json +1/−0
- test/golden/skipped_topic_binding.json +1/−0
- test/golden/unsupported_notify_on_partitioned_queue.json +1/−0
- test/golden/updated_notify_throttle.json +1/−0
CHANGELOG.md view
@@ -1,5 +1,34 @@ # Revision history for pgmq-config +## 0.7.0.0 -- 2026-10-26++### Breaking Changes++- Add `DetectedQueueNameCollision QueueName (NonEmpty Text)` and+ `UnsupportedNotifyOnPartitionedQueue QueueName`, with matching JSON tags and+ fields. Exhaustive `ReconcileAction` matches must handle both constructors.++### New Features++- Add hand-written `ToJSON` instances for `ReconcileAction`, `QueueType`,+ `PartitionConfig` and `ObservedQueueType`, with an aeson `^>=2.2` library dependency.+ Reports use snake_case tags and field names; optional throttle/premake values+ encode as null, preserving declared defaults. Goldens cover every action and queue+ shape. The encoding is a published compatibility surface under design note 020;+ remove downstream orphan instances for these types.++### Bug Fixes++- Report foreign name collisions before creating missing aliases or sub-resources.+- Report partition notifications without enabling or updating unsupported resources.+ Drift still reconciles supported sub-resources against observed shape. Correct+ the guide's reconciliation and idempotence contract.++### Other Changes++- Ephemeral PostgreSQL roots are created/validated as owner-only (`0700`), existing+ owned roots are tightened, unsafe paths are rejected and registry contents survive.+ ## 0.6.1.1 -- 2026-09-25 Coordinated family version bump; the library is unchanged from 0.6.1.0.
pgmq-config.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.4 name: pgmq-config-version: 0.6.1.1+version: 0.7.0.0 synopsis: Declarative queue configuration for PGMQ (PostgreSQL Message Queue) @@ -27,7 +27,9 @@ category: Database build-type: Simple extra-doc-files: CHANGELOG.md-extra-source-files: test/fixtures/pgmq-1.12.0.sql+extra-source-files:+ test/fixtures/pgmq-1.12.0.sql+ test/golden/*.json flag effectful description: Enable effectful integration@@ -64,21 +66,22 @@ OverloadedStrings build-depends:+ aeson ^>=2.2, base >=4.18 && <5, containers >=0.6 && <0.8, generic-lens ^>=2.2 || ^>=2.3, hasql ^>=1.10, hasql-pool ^>=1.4, lens ^>=5.3,- 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.1, if flag(effectful) exposed-modules: Pgmq.Config.Effectful build-depends: effectful-core ^>=2.6 || ^>=2.7,- pgmq-effectful >=0.6 && <0.7,+ pgmq-effectful >=0.7 && <0.8, hs-source-dirs: src default-language: GHC2024 @@ -94,11 +97,14 @@ -with-rtsopts=-N other-modules:+ CollisionSpec ConfigSpec EphemeralDb ForeignQueueSpec+ JsonGoldenSpec NotifyCrashSpec PartitionSpec+ RootPermissionsSpec default-extensions: DeriveGeneric@@ -114,19 +120,20 @@ base >=4.18 && <5, bytestring >=0.11 && <0.13, directory,- ephemeral-pg >=0.3.1.0,+ ephemeral-pg >=0.3.1 && <0.4, generic-lens ^>=2.2 || ^>=2.3, hasql, hasql-pool ^>=1.4, lens ^>=5.3, pg-migrate,- pgmq-config >=0.6 && <0.7,- pgmq-core >=0.6 && <0.7,- pgmq-hasql >=0.6 && <0.7,- pgmq-migration >=0.6 && <0.7,+ pgmq-config >=0.7 && <0.8,+ pgmq-core >=0.7 && <0.8,+ pgmq-hasql >=0.7 && <0.8,+ pgmq-migration >=0.7 && <0.8, postgresql-libpq >=0.10.1 && <0.12, random ^>=1.2, tasty ^>=1.5,+ tasty-golden ^>=2.3.6, tasty-hunit ^>=0.10, text, time ^>=1.14,@@ -139,4 +146,4 @@ cpp-options: -DPGMQ_EFFECTFUL build-depends: effectful-core ^>=2.6 || ^>=2.7,- pgmq-effectful >=0.6 && <0.7,+ pgmq-effectful >=0.7 && <0.8,
src/Pgmq/Config.hs view
@@ -91,7 +91,9 @@ -- -- * A queue whose observed shape (standard, unlogged, or partitioned) -- contradicts the declared one is reported as--- 'Pgmq.Config.Types.DetectedQueueTypeDrift' and nothing is mutated.+-- 'Pgmq.Config.Types.DetectedQueueTypeDrift'. The queue's shape is not mutated;+-- its notify, FIFO-index and binding declarations are still reconciled against+-- its observed shape. -- Changing a queue's type means dropping and recreating it, destroying every -- message it holds; that is an operator's decision, not a startup task. --@@ -99,8 +101,19 @@ -- compared, because @pgmq.list_queues()@ does not report them. Only the -- three-way shape is checked. --+-- * Foreign mixed-case names colliding with the declared physical table are+-- reported as 'DetectedQueueNameCollision'. Without an exact registry row,+-- no queue or sub-resources are created. With an exact row, sub-resources proceed.+-- Detection uses the initial snapshot; it does not serialize foreign alias creation.+--+-- * Notifications on an observed or newly created partitioned queue are reported+-- as 'UnsupportedNotifyOnPartitionedQueue', with no enable, update or re-enable.+-- Existing throttle rows are left unchanged. See+-- @docs/design/015-notification-delivery-contract.md@.+-- -- Calling this on every application startup is the intended usage, and a second--- run against an unchanged config issues no mutations at all. One caveat about+-- run against unchanged config and unchanged, already-satisfied database state+-- issues no mutations. A later run restores crash-lost ordinary throttle rows. One caveat about -- /concurrent/ startups: queue creation and topic binding are serialized -- server-side and converge, but enabling insert notifications on a -- brand-new queue from two replicas at once can fail one of them with SQLSTATE@@ -133,7 +146,10 @@ -- order: exactly one queue-existence action per config -- ('Pgmq.Config.Types.CreatedQueue', 'Pgmq.Config.Types.SkippedQueue', or -- 'Pgmq.Config.Types.DetectedQueueTypeDrift'), then the notify, FIFO, and--- topic-binding actions for that config. Every @Skipped@ action means no+-- topic-binding actions for that config. A missing exact row with colliding names+-- instead reports only 'DetectedQueueNameCollision'; when an exact row exists,+-- that action follows the ordinary existence action and sub-resources proceed.+-- Every @Skipped@ action means no -- statement was issued. ensureQueuesReport :: [QueueConfig] -> Session [ReconcileAction] ensureQueuesReport = ensureQueuesReportWith sessionOps
src/Pgmq/Config/Reconcile.hs view
@@ -14,6 +14,8 @@ import Control.Lens ((^.)) import Data.Generics.Labels () import Data.Int (Int32)+import Data.List (sort)+import Data.List.NonEmpty qualified as NE import Data.Map.Strict qualified as Map import Data.Maybe (fromMaybe) import Data.Set qualified as Set@@ -75,6 +77,7 @@ let existingQueuesByName = Map.fromList [(q ^. #unvalidatedName, q) | q <- existingQueues]+ collisionsByLowerName = Map.fromListWith (++) [(T.toLower n, [n]) | n <- Map.keys existingQueuesByName] existingBindingSet = Set.fromList [ (b ^. #bindingQueueName, topicPatternToText (b ^. #bindingPattern))@@ -92,7 +95,7 @@ concat <$> traverse- (reconcileQueue ops existingQueuesByName existingBindingSet existingNotifyByName existingFifoSet)+ (reconcileQueue ops existingQueuesByName collisionsByLowerName existingBindingSet existingNotifyByName existingFifoSet) configs -- | Reconcile a single queue config against existing state, returning actions taken.@@ -100,88 +103,97 @@ (Monad m) => ReconcileOps m -> Map.Map T.Text UnvalidatedQueue ->+ Map.Map T.Text [T.Text] -> Set.Set (T.Text, T.Text) -> Map.Map T.Text Int32 -> Set.Set T.Text -> QueueConfig -> m [ReconcileAction]-reconcileQueue ops existingQueues existingBindings existingNotify existingFifo cfg = do+reconcileQueue ops existingQueues collisionsByLowerName existingBindings existingNotify existingFifo cfg = do let qn = cfg ^. #queueName qnText = queueNameToText qn declaredType = cfg ^. #queueType - -- Queue creation, or — when the queue is already there — a shape comparison.- -- Drift is reported and never repaired: the only way to change a queue's type- -- is to drop and recreate it, which would destroy its messages.- queueAction <-- case Map.lookup qnText existingQueues of- Just observed ->- let observedType = observedQueueType observed- in pure- [ if declaredShape declaredType == observedType- then SkippedQueue qn- else DetectedQueueTypeDrift qn declaredType observedType- ]- Nothing -> do- case declaredType of- StandardQueue ->- (ops ^. #createQueue) qn- UnloggedQueue ->- (ops ^. #createUnloggedQueue) qn- PartitionedQueue pc ->- let query =- StmtTypes.CreatePartitionedQueue- { queueName = qn,- partitionInterval = pc ^. #partitionInterval,- retentionInterval = pc ^. #retentionInterval- }- in case pc ^. #premake of- Nothing -> (ops ^. #createPartitionedQueue) query- Just n -> (ops ^. #createPartitionedQueueWithPremake) query n- pure [CreatedQueue qn declaredType]+ colliding = NE.nonEmpty (sort (filter (/= qnText) (Map.findWithDefault [] qnText collisionsByLowerName)))+ collisionAction = maybe [] (\names -> [DetectedQueueNameCollision qn names]) colliding+ effectiveShape = maybe (declaredShape declaredType) observedQueueType (Map.lookup qnText existingQueues) - -- Notification. A missing row is enabled; a row whose interval already- -- matches is left strictly alone (re-enabling would reset last_notified_at);- -- a row whose interval differs is updated in place.- notifyAction <- case cfg ^. #notifyInsert of- Nothing -> pure []- Just nc ->- let declaredMs = fromMaybe defaultThrottleMs (nc ^. #throttleMs)- in case Map.lookup qnText existingNotify of- Nothing -> do- (ops ^. #enableNotifyInsert)- StmtTypes.EnableNotifyInsert- { queueName = qn,- throttleIntervalMs = nc ^. #throttleMs- }- pure [EnabledNotify qn (nc ^. #throttleMs)]- Just observedMs- | observedMs == declaredMs -> pure [SkippedNotify qn]- | otherwise -> do- (ops ^. #updateNotifyInsert)- StmtTypes.UpdateNotifyInsert+ if Map.notMember qnText existingQueues && not (null collisionAction)+ then pure collisionAction+ else do+ -- Queue creation, or — when the queue is already there — a shape comparison.+ -- Drift is reported and never repaired: the only way to change a queue's type+ -- is to drop and recreate it, which would destroy its messages.+ queueAction <-+ case Map.lookup qnText existingQueues of+ Just observed ->+ let observedType = observedQueueType observed+ in pure+ [ if declaredShape declaredType == observedType+ then SkippedQueue qn+ else DetectedQueueTypeDrift qn declaredType observedType+ ]+ Nothing -> do+ case declaredType of+ StandardQueue ->+ (ops ^. #createQueue) qn+ UnloggedQueue ->+ (ops ^. #createUnloggedQueue) qn+ PartitionedQueue pc ->+ let query =+ StmtTypes.CreatePartitionedQueue+ { queueName = qn,+ partitionInterval = pc ^. #partitionInterval,+ retentionInterval = pc ^. #retentionInterval+ }+ in case pc ^. #premake of+ Nothing -> (ops ^. #createPartitionedQueue) query+ Just n -> (ops ^. #createPartitionedQueueWithPremake) query n+ pure [CreatedQueue qn declaredType]++ -- Notification. A missing row is enabled; a row whose interval already+ -- matches is left strictly alone (re-enabling would reset last_notified_at);+ -- a row whose interval differs is updated in place.+ notifyAction <- case cfg ^. #notifyInsert of+ Nothing -> pure []+ Just _ | effectiveShape == ObservedPartitioned -> pure [UnsupportedNotifyOnPartitionedQueue qn]+ Just nc ->+ let declaredMs = fromMaybe defaultThrottleMs (nc ^. #throttleMs)+ in case Map.lookup qnText existingNotify of+ Nothing -> do+ (ops ^. #enableNotifyInsert)+ StmtTypes.EnableNotifyInsert { queueName = qn,- throttleIntervalMs = declaredMs+ throttleIntervalMs = nc ^. #throttleMs }- pure [UpdatedNotifyThrottle qn observedMs declaredMs]+ pure [EnabledNotify qn (nc ^. #throttleMs)]+ Just observedMs+ | observedMs == declaredMs -> pure [SkippedNotify qn]+ | otherwise -> do+ (ops ^. #updateNotifyInsert)+ StmtTypes.UpdateNotifyInsert+ { queueName = qn,+ throttleIntervalMs = declaredMs+ }+ pure [UpdatedNotifyThrottle qn observedMs declaredMs] - -- FIFO index. The catalog snapshot makes the report truthful; the underlying- -- pgmq.create_fifo_index is CREATE INDEX IF NOT EXISTS, so losing a race with- -- a concurrent replica degrades to a no-op rather than an error.- fifoAction <-- if cfg ^. #fifoIndex- then- if Set.member qnText existingFifo- then pure [SkippedFifoIndex qn]- else do- (ops ^. #createFifoIndex) qn- pure [CreatedFifoIndex qn]- else pure []+ -- FIFO index. The catalog snapshot makes the report truthful; the underlying+ -- pgmq.create_fifo_index is CREATE INDEX IF NOT EXISTS, so losing a race with+ -- a concurrent replica degrades to a no-op rather than an error.+ fifoAction <-+ if cfg ^. #fifoIndex+ then+ if Set.member qnText existingFifo+ then pure [SkippedFifoIndex qn]+ else do+ (ops ^. #createFifoIndex) qn+ pure [CreatedFifoIndex qn]+ else pure [] - -- Topic bindings- bindingActions <- concat <$> traverse (reconcileBinding ops qn qnText existingBindings) (cfg ^. #topicBindings)+ -- Topic bindings+ bindingActions <- concat <$> traverse (reconcileBinding ops qn qnText existingBindings) (cfg ^. #topicBindings) - pure (queueAction ++ notifyAction ++ fifoAction ++ bindingActions)+ pure (queueAction ++ collisionAction ++ notifyAction ++ fifoAction ++ bindingActions) -- | The shape a declared config asks for, reduced to what @pgmq.list_queues()@ -- can actually report. Partition interval, retention and premake are deliberately
src/Pgmq/Config/Types.hs view
@@ -29,9 +29,11 @@ ) where -import Control.Lens ((%~), (&))+import Control.Lens ((%~), (&), (^.))+import Data.Aeson (Encoding, KeyValue, Series, ToJSON (..), object, pairs, (.=)) import Data.Generics.Labels () import Data.Int (Int32)+import Data.List.NonEmpty (NonEmpty) import Data.Text (Text) import GHC.Generics (Generic) import Pgmq.Types (QueueName, TopicPattern)@@ -123,10 +125,11 @@ | -- | The queue exists but its observed shape contradicts the declared one. -- Fields: queue, declared type, observed type. --- -- Nothing was mutated and nothing will be: converting a queue between- -- standard, unlogged, and partitioned means dropping and recreating it,- -- destroying every message it holds, which a startup reconciler must never- -- do. Resolving the drift is an operator decision. This action replaces+ -- The queue itself is not mutated: conversion between standard, unlogged,+ -- and partitioned requires destroying its messages. Notification, FIFO-index+ -- and topic-binding declarations are still reconciled against the observed+ -- queue and each reports its own action. Resolving drift is an operator+ -- decision. This action replaces -- 'SkippedQueue' for the queue it concerns, so the report still carries -- exactly one queue-existence action per declared config. --@@ -135,6 +138,21 @@ -- and retention settings, because @pgmq.list_queues()@ does not report -- them. DetectedQueueTypeDrift !QueueName !QueueType !ObservedQueueType+ | -- | Foreign registry names share the declared name's lowercased physical+ -- table. Fields: declared queue, every colliding registry name in ascending+ -- lexical order. Without an exact row this replaces 'CreatedQueue' and no+ -- notification, FIFO-index or binding declarations run. With an exact row,+ -- this follows the existence action and sub-resources proceed on that row.+ -- This action mutates nothing. Detection uses the initial snapshot; a foreign+ -- client can still create an alias afterward. Resolving aliases follows+ -- @docs/design/016-queue-name-validation.md@.+ DetectedQueueNameCollision !QueueName !(NonEmpty Text)+ | -- | Insert notifications were declared on an observed or newly created+ -- partitioned queue. Its cloned leaf triggers cannot deliver on+ -- 'Pgmq.Types.notifyChannelName'; see+ -- @docs/design/015-notification-delivery-contract.md@. Nothing was enabled,+ -- updated or re-enabled; an existing foreign throttle row is left unchanged.+ UnsupportedNotifyOnPartitionedQueue !QueueName deriving stock (Show) -- | The throttle interval pgmq applies when none is given: 250 milliseconds.@@ -181,3 +199,99 @@ withTopicBinding :: TopicPattern -> QueueConfig -> QueueConfig withTopicBinding pat cfg = cfg & #topicBindings %~ (++ [pat])++-- JSON wire encodings ---------------------------------------------------------++encodeFields :: [Series] -> Encoding+encodeFields = pairs . mconcat++-- | Encodes as @{"type":"standard"}@, @{"type":"unlogged"}@, or+-- @{"type":"partitioned","partition_interval":…,"retention_interval":…,"premake":…}@.+-- See @docs/design/020-json-wire-encodings.md@.+queueTypeFields :: (KeyValue e kv) => QueueType -> [kv]+queueTypeFields qt = case qt of+ StandardQueue -> ["type" .= ("standard" :: Text)]+ UnloggedQueue -> ["type" .= ("unlogged" :: Text)]+ PartitionedQueue pc -> ("type" .= ("partitioned" :: Text)) : partitionConfigFields pc++-- | JSON wire contract: @docs/design/020-json-wire-encodings.md@.+instance ToJSON QueueType where+ toJSON = object . queueTypeFields+ toEncoding = encodeFields . queueTypeFields++-- | @premake@ is @null@ when creation uses the server default. See+-- @docs/design/020-json-wire-encodings.md@.+partitionConfigFields :: (KeyValue e kv) => PartitionConfig -> [kv]+partitionConfigFields pc =+ [ "partition_interval" .= (pc ^. #partitionInterval),+ "retention_interval" .= (pc ^. #retentionInterval),+ "premake" .= (pc ^. #premake)+ ]++-- | JSON wire contract: @docs/design/020-json-wire-encodings.md@.+instance ToJSON PartitionConfig where+ toJSON = object . partitionConfigFields+ toEncoding = encodeFields . partitionConfigFields++-- | Encodes as the bare strings @"standard"@, @"unlogged"@, @"partitioned"@.+observedQueueTypeText :: ObservedQueueType -> Text+observedQueueTypeText ot = case ot of+ ObservedStandard -> "standard"+ ObservedUnlogged -> "unlogged"+ ObservedPartitioned -> "partitioned"++-- | JSON wire contract: @docs/design/020-json-wire-encodings.md@.+instance ToJSON ObservedQueueType where+ toJSON = toJSON . observedQueueTypeText+ toEncoding = toEncoding . observedQueueTypeText++-- | A tagged object: @"action"@ holds the snake_case constructor name and the+-- remaining keys name the fields. @enabled_notify@'s @throttle_interval_ms@+-- is @null@ when the config declared the pgmq default ('defaultThrottleMs')+-- rather than a number; that is a different fact from declaring @250@.+-- Encode-only. The encoding is a published compatibility surface; see+-- @docs/design/020-json-wire-encodings.md@. Every constructor added later+-- must be encoded by the same rule and pinned in+-- @pgmq-config/test/JsonGoldenSpec.hs@.+reconcileActionFields :: (KeyValue e kv) => ReconcileAction -> [kv]+reconcileActionFields action = case action of+ CreatedQueue q t ->+ ["action" .= tag "created_queue", "queue" .= q, "queue_type" .= t]+ EnabledNotify q ms ->+ ["action" .= tag "enabled_notify", "queue" .= q, "throttle_interval_ms" .= ms]+ CreatedFifoIndex q ->+ ["action" .= tag "created_fifo_index", "queue" .= q]+ BoundTopic q p ->+ ["action" .= tag "bound_topic", "queue" .= q, "pattern" .= p]+ SkippedQueue q ->+ ["action" .= tag "skipped_queue", "queue" .= q]+ SkippedNotify q ->+ ["action" .= tag "skipped_notify", "queue" .= q]+ SkippedFifoIndex q ->+ ["action" .= tag "skipped_fifo_index", "queue" .= q]+ SkippedTopicBinding q p ->+ ["action" .= tag "skipped_topic_binding", "queue" .= q, "pattern" .= p]+ UpdatedNotifyThrottle q observed declared ->+ [ "action" .= tag "updated_notify_throttle",+ "queue" .= q,+ "observed_throttle_interval_ms" .= observed,+ "declared_throttle_interval_ms" .= declared+ ]+ DetectedQueueTypeDrift q declared observed ->+ [ "action" .= tag "detected_queue_type_drift",+ "queue" .= q,+ "declared" .= declared,+ "observed" .= observed+ ]+ DetectedQueueNameCollision q names ->+ ["action" .= tag "detected_queue_name_collision", "queue" .= q, "colliding_names" .= names]+ UnsupportedNotifyOnPartitionedQueue q ->+ ["action" .= tag "unsupported_notify_on_partitioned_queue", "queue" .= q]+ where+ tag :: Text -> Text+ tag = id++-- | JSON wire contract: @docs/design/020-json-wire-encodings.md@.+instance ToJSON ReconcileAction where+ toJSON = object . reconcileActionFields+ toEncoding = encodeFields . reconcileActionFields
+ test/CollisionSpec.hs view
@@ -0,0 +1,80 @@+{-# LANGUAGE OverloadedStrings #-}++-- | Schema-selectable isolated foreign-name fixtures for each backend.+module CollisionSpec (tests) where++import Control.Monad (forM_)+import Data.Foldable (toList)+import Data.List (sort)+import Data.Text (Text)+import Hasql.Pool qualified as Pool+import Hasql.Session qualified as Session+import PartitionSpec (backends, isolated, reconcile, right, session)+import Pgmq.Config+import Pgmq.Types (parseQueueName, parseTopicPattern)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertFailure, testCase, (@?=))++tests :: TestTree+tests =+ testGroup+ "CollisionSpec"+ [ testGroup+ (if backend then "effectful" else "direct")+ [ testCase "single alias blocks every declared sub-resource, including repeat" (collision backend ["Billing_Events"]),+ testCase "multiple aliases block every declared sub-resource, including repeat" (collision backend ["Billing_Events", "BILLING_EVENTS"]),+ testCase "exact row reports collision while reconciling sub-resources" (exactRow backend),+ testCase "noncolliding foreign name leaves declared queues unaffected" (noncolliding backend)+ ]+ | backend <- backends+ ]++collision :: Bool -> [Text] -> IO ()+collision backend names = isolated $ \pool -> do+ forM_ names $ \name -> sql pool ("SELECT pgmq.create('" <> name <> "')")+ q <- right (parseQueueName "billing_events")+ other <- right (parseQueueName "other")+ pat <- right (parseTopicPattern "billing.*")+ let config = withTopicBinding pat (withFifoIndex (withNotifyInsert (Just 0) (standardQueue q)))+ forM_ [1 :: Int, 2] $ \_ -> do+ actions <- right =<< reconcile backend pool [config, standardQueue other]+ case actions of+ [DetectedQueueNameCollision actual colliding, next] -> do+ actual @?= q+ sort (toList colliding) @?= sort names+ case next of+ CreatedQueue n _ -> n @?= other+ SkippedQueue n -> n @?= other+ unexpected -> assertFailure (show unexpected)+ unexpected -> assertFailure (show unexpected)+ sql pool ("DO $$ BEGIN IF (SELECT count(*) FROM pgmq.meta WHERE lower(queue_name)='billing_events') <> " <> (if length names == 1 then "1" else "2") <> " THEN RAISE EXCEPTION 'collision inserted metadata alias'; END IF; END $$")+ sql pool "DO $$ BEGIN IF EXISTS (SELECT FROM pgmq.notify_insert_throttle WHERE queue_name='billing_events') OR to_regclass('pgmq.q_billing_events_fifo_idx') IS NOT NULL OR EXISTS (SELECT FROM pgmq.topic_bindings WHERE queue_name='billing_events') THEN RAISE EXCEPTION 'collision mutated sub-resources'; END IF; IF NOT EXISTS (SELECT FROM pgmq.meta WHERE queue_name='other') THEN RAISE EXCEPTION 'unrelated queue blocked'; END IF; END $$"++sql :: Pool.Pool -> Text -> IO ()+sql pool command = session pool (Session.script command)++exactRow :: Bool -> IO ()+exactRow backend = isolated $ \pool -> do+ sql pool "SELECT pgmq.create('Billing_Events'); SELECT pgmq.create('BILLING_EVENTS'); SELECT pgmq.create('billing_events')"+ q <- right (parseQueueName "billing_events")+ other <- right (parseQueueName "other")+ pat <- right (parseTopicPattern "billing.*")+ let config = withTopicBinding pat (withFifoIndex (withNotifyInsert (Just 0) (standardQueue q)))+ actions <- right =<< reconcile backend pool [config, standardQueue other]+ case actions of+ [SkippedQueue n, DetectedQueueNameCollision c names, EnabledNotify e _, CreatedFifoIndex f, BoundTopic b _, CreatedQueue o _] -> do+ [n, c, e, f, b] @?= replicate 5 q+ o @?= other+ sort (toList names) @?= ["BILLING_EVENTS", "Billing_Events"]+ unexpected -> assertFailure (show unexpected)+ sql pool "DO $$ BEGIN IF (SELECT count(*) FROM pgmq.meta WHERE lower(queue_name)='billing_events') <> 3 OR NOT EXISTS (SELECT FROM pgmq.notify_insert_throttle WHERE queue_name='billing_events') OR to_regclass('pgmq.q_billing_events_fifo_idx') IS NULL OR NOT EXISTS (SELECT FROM pgmq.topic_bindings WHERE queue_name='billing_events') THEN RAISE EXCEPTION 'exact row sub-resources missing'; END IF; END $$"++noncolliding :: Bool -> IO ()+noncolliding backend = isolated $ \pool -> do+ sql pool "SELECT pgmq.create('ForeignQueue')"+ q <- right (parseQueueName "billing_events")+ other <- right (parseQueueName "other")+ actions <- right =<< reconcile backend pool [standardQueue q, standardQueue other]+ case actions of+ [CreatedQueue n _, CreatedQueue o _] -> [n, o] @?= [q, other]+ unexpected -> assertFailure (show unexpected)
test/ConfigSpec.hs view
@@ -342,6 +342,8 @@ actionForQueue qn (SkippedTopicBinding q _) = q == qn actionForQueue qn (UpdatedNotifyThrottle q _ _) = q == qn actionForQueue qn (DetectedQueueTypeDrift q _ _) = q == qn+actionForQueue qn (DetectedQueueNameCollision q _) = q == qn+actionForQueue qn (UnsupportedNotifyOnPartitionedQueue q) = q == qn -- | Silent-variant version of 'testEnsureQueuesIdempotent': call 'ensureQueues' -- twice with the same standard-queue config and confirm the queue exists exactly
test/EphemeralDb.hs view
@@ -5,13 +5,15 @@ ( -- * Database setup withPgmqDb, ephemeralConfig,+ ensureOwnerOnlyRoot, -- * Re-exports 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@@ -32,8 +34,11 @@ 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.@@ -51,11 +56,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 pgmq schema installed
+ test/JsonGoldenSpec.hs view
@@ -0,0 +1,62 @@+{-# LANGUAGE OverloadedStrings #-}++-- | One golden per 'ReconcileAction' constructor. A constructor without a+-- golden is a defect: the report is a published wire shape (see+-- @docs/design/020-json-wire-encodings.md@) once a consumer logs or serves it.+module JsonGoldenSpec (tests) where++import Data.Aeson (ToJSON (..), Value, decode, encode)+import Data.List.NonEmpty (NonEmpty (..))+import Pgmq.Config.Types+import Pgmq.Types (QueueName, TopicPattern, parseQueueName, parseTopicPattern)+import Test.Tasty (TestTree, localOption, testGroup)+import Test.Tasty.Golden (NoCreateFile (..), goldenVsString)+import Test.Tasty.HUnit (assertEqual, testCase)++tests :: TestTree+tests =+ localOption (NoCreateFile True) $+ testGroup+ "ReconcileAction wire encodings"+ [ golden "created_queue_standard" (CreatedQueue q StandardQueue),+ golden "created_queue_unlogged" (CreatedQueue q UnloggedQueue),+ golden "created_queue_partitioned" (CreatedQueue q (PartitionedQueue partition)),+ golden "created_queue_partitioned_default_premake" (CreatedQueue q (PartitionedQueue partition {premake = Nothing})),+ golden "enabled_notify" (EnabledNotify q (Just 1000)),+ golden "enabled_notify_default" (EnabledNotify q Nothing),+ golden "created_fifo_index" (CreatedFifoIndex q),+ golden "bound_topic" (BoundTopic q pat),+ golden "skipped_queue" (SkippedQueue q),+ golden "skipped_notify" (SkippedNotify q),+ golden "skipped_fifo_index" (SkippedFifoIndex q),+ golden "skipped_topic_binding" (SkippedTopicBinding q pat),+ golden "updated_notify_throttle" (UpdatedNotifyThrottle q 250 1000),+ golden "detected_queue_type_drift" (DetectedQueueTypeDrift q UnloggedQueue ObservedPartitioned),+ golden "detected_queue_name_collision" (DetectedQueueNameCollision q ("ORDERS" :| ["Orders"])),+ golden "unsupported_notify_on_partitioned_queue" (UnsupportedNotifyOnPartitionedQueue q),+ golden "queue_type_standard" StandardQueue,+ golden "queue_type_unlogged" UnloggedQueue,+ golden "queue_type_partitioned" (PartitionedQueue partition),+ golden "partition_config" partition,+ golden "partition_config_default" (partition {premake = Nothing}),+ golden "observed_standard" ObservedStandard,+ golden "observed_unlogged" ObservedUnlogged,+ golden "observed_partitioned" ObservedPartitioned+ ]+ where+ golden name value =+ testGroup+ name+ [ goldenVsString "bytes" ("test/golden/" <> name <> ".json") (pure (encode value <> "\n")),+ testCase "toJSON and toEncoding agree" $+ assertEqual "encoder parity" (Just (toJSON value)) (decode (encode value) :: Maybe Value)+ ]++q :: QueueName+q = either (error . show) id (parseQueueName "orders")++pat :: TopicPattern+pat = either (error . show) id (parseTopicPattern "orders.*")++partition :: PartitionConfig+partition = PartitionConfig {partitionInterval = "daily", retentionInterval = "7 days", premake = Just 2}
test/Main.hs view
@@ -2,11 +2,14 @@ module Main (main) where +import CollisionSpec qualified import ConfigSpec qualified-import EphemeralDb (withPgmqDb)+import EphemeralDb (ensureOwnerOnlyRoot, withPgmqDb) import ForeignQueueSpec qualified+import JsonGoldenSpec qualified import NotifyCrashSpec qualified import PartitionSpec qualified+import RootPermissionsSpec qualified import Test.Tasty (defaultMain, testGroup) main :: IO ()@@ -15,8 +18,12 @@ let tree = testGroup "pgmq-config"- [ ConfigSpec.tests pool,+ [ RootPermissionsSpec.tests ensureOwnerOnlyRoot,+ JsonGoldenSpec.tests,+ ConfigSpec.tests pool, PartitionSpec.tests,+ -- Foreign names require an isolated, schema-selectable server.+ CollisionSpec.tests, -- NotifyCrashSpec manages its own PostgreSQL instance: it crashes -- the server, which the shared pool above could not survive. NotifyCrashSpec.tests,
test/NotifyCrashSpec.hs view
@@ -35,6 +35,7 @@ ) import EphemeralDb (ephemeralConfig) 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@@ -70,7 +71,9 @@ -- | Channel of the notification delivered by the post-crash send, if any. obsNotifyAfterCrash :: !(Maybe ByteString), -- | Queue names in the throttle table after a reconcile ran.- obsThrottlesAfterReconcile :: ![Text]+ obsThrottlesAfterReconcile :: ![Text],+ -- | Restored nonzero interval, without restarting the application.+ obsRestoredInterval :: !Int64 } tests :: TestTree@@ -102,6 +105,7 @@ testCase "post-crash: a reconcile restores the throttle row" $ do obs <- getObs obsThrottlesAfterReconcile obs @?= [queueNameToText (obsQueueName obs)]+ obsRestoredInterval obs @?= 3600000 ] -- | Start a dedicated PostgreSQL instance, enable notify, crash it, recover it,@@ -118,7 +122,7 @@ db0 <- readIORef ref installPgmq db0 - -- Pre-crash: create the queue, enable unthrottled notify, send one message.+ -- Pre-crash: create the queue, enable a nonzero throttle, send one message. -- No listener is opened yet: an immediate shutdown kills every pre-crash -- connection, so a listener created here could never see the assertion's -- notification.@@ -128,7 +132,7 @@ Sessions.enableNotifyInsert StmtTypes.EnableNotifyInsert { StmtTypes.queueName = qn,- StmtTypes.throttleIntervalMs = Just 0+ StmtTypes.throttleIntervalMs = Just 3600000 } runSession pool (sendProbe qn "before-crash") -- ephemeral-pg runs PostgreSQL with fsync, synchronous_commit and@@ -153,8 +157,9 @@ _ <- runSession pool (sendProbe qn "after-crash") awaitNotify conn 20 - runSession pool (ensureQueues [withNotifyInsert (Just 0) (standardQueue qn)])+ runSession pool (ensureQueues [withNotifyInsert (Just 3600000) (standardQueue qn)]) throttlesAfterReconcile <- listThrottleNames pool+ restored <- runSession pool (Session.statement (queueNameToText qn) (preparable "SELECT throttle_interval_ms::bigint FROM pgmq.notify_insert_throttle WHERE queue_name=$1" (E.param (E.nonNullable E.text)) (D.singleRow (D.column (D.nonNullable D.int8))))) pure CrashObservations@@ -164,7 +169,8 @@ obsTriggersAfterCrash = triggers, obsQueueLengthAfterCrash = queueLength, obsNotifyAfterCrash = LibPQ.notifyRelname <$> notified,- obsThrottlesAfterReconcile = throttlesAfterReconcile+ obsThrottlesAfterReconcile = throttlesAfterReconcile,+ obsRestoredInterval = restored } -- | Stop PostgreSQL with SIGQUIT and start it again on the same data directory.@@ -297,7 +303,8 @@ awaitNotify conn attempts | attempts <= 0 = pure Nothing | otherwise = do- _ <- LibPQ.consumeInput conn+ ok <- LibPQ.consumeInput conn+ unless ok (assertFailure "libpq consumeInput failed") pending <- LibPQ.notifies conn case pending of Just n -> pure (Just n)
test/PartitionSpec.hs view
@@ -1,6 +1,6 @@ {-# LANGUAGE CPP #-} -module PartitionSpec (tests) where+module PartitionSpec (tests, backends, isolated, reconcile, right, session) where import Control.Exception (finally) import Control.Monad (forM_)@@ -71,6 +71,43 @@ counts pool qn >>= (@?= [expected, expected]) | requested <- [Nothing, Just 2] ]+ ++ [ testCase "unsupported partition notify installs no trigger or throttle" $ isolated $ \pool -> withPartman pool $ do+ qn <- right (parseQueueName "partition_notify")+ let config = withNotifyInsert (Just 0) (partitionedQueue qn (PartitionConfig "10" "100" Nothing))+ first <- right =<< reconcile effectful pool [config]+ case first of+ [CreatedQueue q _, UnsupportedNotifyOnPartitionedQueue n] -> [q, n] @?= [qn, qn]+ other -> assertFailure (show other)+ second <- right =<< reconcile effectful pool [config]+ case second of+ [SkippedQueue q, UnsupportedNotifyOnPartitionedQueue n] -> [q, n] @?= [qn, qn]+ other -> assertFailure (show other)+ session pool (Session.script "DO $$ BEGIN IF EXISTS (SELECT FROM pgmq.notify_insert_throttle WHERE queue_name='partition_notify') OR EXISTS (SELECT FROM pg_trigger WHERE tgrelid='pgmq.q_partition_notify'::regclass AND tgname='trigger_notify_queue_insert_listeners') THEN RAISE EXCEPTION 'unsupported notification installed'; END IF; END $$"),+ testCase "unsupported existing partition throttle remains unchanged" $ isolated $ \pool -> withPartman pool $ do+ qn <- right (parseQueueName "partition_existing")+ session pool (Session.script "SELECT pgmq.create_partitioned('partition_existing','10','100'); SELECT pgmq.enable_notify_insert('partition_existing',250); CREATE TABLE public.before_throttle AS SELECT * FROM pgmq.notify_insert_throttle")+ actions <- right =<< reconcile effectful pool [withNotifyInsert (Just 500) (partitionedQueue qn (PartitionConfig "10" "100" Nothing))]+ case actions of+ [SkippedQueue q, UnsupportedNotifyOnPartitionedQueue n] -> [q, n] @?= [qn, qn]+ other -> assertFailure (show other)+ session pool (Session.script "DO $$ BEGIN IF EXISTS ((TABLE public.before_throttle EXCEPT TABLE pgmq.notify_insert_throttle) UNION ALL (TABLE pgmq.notify_insert_throttle EXCEPT TABLE public.before_throttle)) THEN RAISE EXCEPTION 'partition throttle changed'; END IF; END $$"),+ testCase "observed partition declared standard reports drift and unsupported" $ isolated $ \pool -> withPartman pool $ do+ qn <- right (parseQueueName "partition_drift")+ session pool (Session.script "SELECT pgmq.create_partitioned('partition_drift','10','100')")+ actions <- right =<< reconcile effectful pool [withNotifyInsert (Just 0) (standardQueue qn)]+ case actions of+ [DetectedQueueTypeDrift q StandardQueue ObservedPartitioned, UnsupportedNotifyOnPartitionedQueue n] -> [q, n] @?= [qn, qn]+ other -> assertFailure (show other)+ session pool (Session.script "DO $$ BEGIN IF EXISTS (SELECT FROM pgmq.notify_insert_throttle WHERE queue_name='partition_drift') THEN RAISE EXCEPTION 'observed partition notification installed'; END IF; END $$"),+ testCase "observed standard declared partition reconciles notifications" $ isolated $ \pool -> do+ qn <- right (parseQueueName "standard_drift")+ session pool (Session.script "SELECT pgmq.create('standard_drift')")+ actions <- right =<< reconcile effectful pool [withNotifyInsert (Just 0) (partitionedQueue qn (PartitionConfig "10" "100" Nothing))]+ case actions of+ [DetectedQueueTypeDrift q (PartitionedQueue _) ObservedStandard, EnabledNotify n (Just 0)] -> [q, n] @?= [qn, qn]+ other -> assertFailure (show other)+ session pool (Session.script "DO $$ BEGIN IF NOT EXISTS (SELECT FROM pgmq.notify_insert_throttle WHERE queue_name='standard_drift' AND throttle_interval_ms=0) THEN RAISE EXCEPTION 'standard notification missing'; END IF; END $$")+ ] ++ [ testCase "zero and negative counts fail on new queues" $ forM_ [0, -1] $ \n -> isolated $ \pool -> withPartman pool $ do stock <- (== Just "1.12.0") <$> lookupEnv "PGMQ_TEST_SCHEMA_VERSION" qn <- right (parseQueueName "invalid_partition_config")
+ 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/golden/bound_topic.json view
@@ -0,0 +1,1 @@+{"action":"bound_topic","queue":"orders","pattern":"orders.*"}
+ test/golden/created_fifo_index.json view
@@ -0,0 +1,1 @@+{"action":"created_fifo_index","queue":"orders"}
+ test/golden/created_queue_partitioned.json view
@@ -0,0 +1,1 @@+{"action":"created_queue","queue":"orders","queue_type":{"type":"partitioned","partition_interval":"daily","retention_interval":"7 days","premake":2}}
+ test/golden/created_queue_partitioned_default_premake.json view
@@ -0,0 +1,1 @@+{"action":"created_queue","queue":"orders","queue_type":{"type":"partitioned","partition_interval":"daily","retention_interval":"7 days","premake":null}}
+ test/golden/created_queue_standard.json view
@@ -0,0 +1,1 @@+{"action":"created_queue","queue":"orders","queue_type":{"type":"standard"}}
+ test/golden/created_queue_unlogged.json view
@@ -0,0 +1,1 @@+{"action":"created_queue","queue":"orders","queue_type":{"type":"unlogged"}}
+ test/golden/detected_queue_name_collision.json view
@@ -0,0 +1,1 @@+{"action":"detected_queue_name_collision","queue":"orders","colliding_names":["ORDERS","Orders"]}
+ test/golden/detected_queue_type_drift.json view
@@ -0,0 +1,1 @@+{"action":"detected_queue_type_drift","queue":"orders","declared":{"type":"unlogged"},"observed":"partitioned"}
+ test/golden/enabled_notify.json view
@@ -0,0 +1,1 @@+{"action":"enabled_notify","queue":"orders","throttle_interval_ms":1000}
+ test/golden/enabled_notify_default.json view
@@ -0,0 +1,1 @@+{"action":"enabled_notify","queue":"orders","throttle_interval_ms":null}
+ test/golden/observed_partitioned.json view
@@ -0,0 +1,1 @@+"partitioned"
+ test/golden/observed_standard.json view
@@ -0,0 +1,1 @@+"standard"
+ test/golden/observed_unlogged.json view
@@ -0,0 +1,1 @@+"unlogged"
+ test/golden/partition_config.json view
@@ -0,0 +1,1 @@+{"partition_interval":"daily","retention_interval":"7 days","premake":2}
+ test/golden/partition_config_default.json view
@@ -0,0 +1,1 @@+{"partition_interval":"daily","retention_interval":"7 days","premake":null}
+ test/golden/queue_type_partitioned.json view
@@ -0,0 +1,1 @@+{"type":"partitioned","partition_interval":"daily","retention_interval":"7 days","premake":2}
+ test/golden/queue_type_standard.json view
@@ -0,0 +1,1 @@+{"type":"standard"}
+ test/golden/queue_type_unlogged.json view
@@ -0,0 +1,1 @@+{"type":"unlogged"}
+ test/golden/skipped_fifo_index.json view
@@ -0,0 +1,1 @@+{"action":"skipped_fifo_index","queue":"orders"}
+ test/golden/skipped_notify.json view
@@ -0,0 +1,1 @@+{"action":"skipped_notify","queue":"orders"}
+ test/golden/skipped_queue.json view
@@ -0,0 +1,1 @@+{"action":"skipped_queue","queue":"orders"}
+ test/golden/skipped_topic_binding.json view
@@ -0,0 +1,1 @@+{"action":"skipped_topic_binding","queue":"orders","pattern":"orders.*"}
+ test/golden/unsupported_notify_on_partitioned_queue.json view
@@ -0,0 +1,1 @@+{"action":"unsupported_notify_on_partitioned_queue","queue":"orders"}
+ test/golden/updated_notify_throttle.json view
@@ -0,0 +1,1 @@+{"action":"updated_notify_throttle","queue":"orders","observed_throttle_interval_ms":250,"declared_throttle_interval_ms":1000}