publishing_platform_message_queue_consumer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: dcb24019c8122307ea1065e358a617790aedfd2b286e8830b46861518a7d8cf9
4
+ data.tar.gz: 79abd82a0b158c72d7323655df80a73a26408421aeb81ccbf31ba7cf0a2960fb
5
+ SHA512:
6
+ metadata.gz: 167902fc49cd11027155662b8ab3f3f3792bdc9e7fc40ea75689bec513a38a0da1af9f02a82bf19786b6015d0b6182f2a7e535b126a0d048de96f9891316d375
7
+ data.tar.gz: 393f90f3603f6ab1e43595bfe863f00b0fd3ce841a8447a8e4a3e5820dac7051490bb0e278a90b76345a5280343437b5cb591036a4a10491bfdbb0c60b98be43
data/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Publishing Platform
4
+ Copyright (C) 2015 Crown copyright (Government Digital Service)
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,133 @@
1
+ # Publishing Platform Message Queue Consumer
2
+ publishing_platform_message_queue_consumer is a wrapper around the
3
+ [Bunny](https://github.com/ruby-amqp/bunny) gem for communicating with
4
+ [RabbitMQ](https://www.rabbitmq.com/). The user of publishing_platform_message_queue_consumer
5
+ supplies some configuration and a class that processes messages.
6
+
7
+ RabbitMQ is a multi-producer, multi-consumer message queue that allows
8
+ applications to subscribe to notifications published by other applications.
9
+
10
+ Publishing Platform [publishing-api](https://github.com/publishing-platform/publishing-api) publishes
11
+ a message to RabbitMQ when a ContentItem is added or changed. Other
12
+ applications (consumers) subscribe to these messages so that they can perform
13
+ actions such as emailing users or updating a search index.
14
+
15
+ ## Usage
16
+
17
+ [Add the gem to your Gemfile](https://rubygems.org/gems/publishing_platform_message_queue_consumer).
18
+
19
+ Add a rake task like the following example:
20
+
21
+ ```ruby
22
+ # lib/tasks/message_queue.rake
23
+ namespace :message_queue do
24
+ desc "Run worker to consume messages from rabbitmq"
25
+ task consumer: :environment do
26
+ PublishingPlatformMessageQueueConsumer::Consumer.new(
27
+ queue_name: "some-queue",
28
+ processor: MyProcessor.new,
29
+ ).run
30
+ end
31
+ end
32
+ ```
33
+
34
+ `PublishingPlatformMessageQueueConsumer::Consumer` expects the [`RABBITMQ_URL` environment
35
+ variable](https://github.com/ruby-amqp/bunny/blob/066496d/docs/guides/connecting.md#paas-environments)
36
+ to be set to an AMQP connection string, for example:
37
+
38
+ ```sh
39
+ RABBITMQ_URL=amqp://mrbean:hunter2@rabbitmq.example.com:5672
40
+ ```
41
+
42
+ Define a class that will process the messages:
43
+
44
+ ```ruby
45
+ # eg. app/queue_consumers/my_processor.rb
46
+ class MyProcessor
47
+ def process(message)
48
+ # do something cool
49
+ end
50
+ end
51
+ ```
52
+
53
+ You can start the worker by running the `message_queue:consumer` Rake task.
54
+
55
+ ```sh
56
+ bundle exec rake message_queue:consumer
57
+ ```
58
+
59
+ ### Process a message
60
+
61
+ Once you receive a message, you *must* tell RabbitMQ once you've processed it. This
62
+ is called _acking_. You can also _discard_ the message, or _retry_ it.
63
+
64
+ ```ruby
65
+ class MyProcessor
66
+ def process(message)
67
+ result = do_something_with(message)
68
+
69
+ if result.ok?
70
+ # Ack the message when it has been processed correctly.
71
+ message.ack
72
+ elsif result.failed_temporarily?
73
+ # Retry the message to make RabbitMQ send the message again later.
74
+ message.retry
75
+ elsif result.failed_permanently?
76
+ # Discard the message when it can't be processed.
77
+ message.discard
78
+ end
79
+ end
80
+ end
81
+
82
+ ### Test your processor
83
+
84
+ publishing_platform_message_queue_consumer provides a test helper for your processor.
85
+
86
+ ```ruby
87
+ # e.g. spec/queue_consumers/my_processor_spec.rb
88
+ require 'test_helper'
89
+ require 'publishing_platform_message_queue_consumer/test_helpers'
90
+
91
+ describe MyProcessor do
92
+ it_behaves_like "a message queue processor"
93
+ end
94
+ ```
95
+
96
+ This will verify that your processor class implements the correct methods. You
97
+ should add your own tests to verify its behaviour.
98
+
99
+ You can use `PublishingPlatformMessageQueueConsumer::MockMessage` to test the processor
100
+ behaviour. When using the mock, you can verify it acknowledged, retried or
101
+ discarded. For example, with `MyProcessor` above:
102
+
103
+ ```ruby
104
+ it "acks incoming messages" do
105
+ message = PublishingPlatformMessageQueueConsumer::MockMessage.new
106
+
107
+ MyProcessor.new.process(message)
108
+
109
+ expect(message).to be_acked
110
+ end
111
+ ```
112
+
113
+ For more test cases [see the spec for the mock itself](/spec/publishing_platform_message_queue_consumer/test_helpers/mock_message_spec.rb).
114
+
115
+ ### Run the test suite
116
+
117
+ ```bash
118
+ bundle exec rake spec
119
+ ```
120
+
121
+ ## Further reading
122
+
123
+ - [Bunny](https://github.com/ruby-amqp/bunny) is the RabbitMQ client we use.
124
+ - [The Bunny Guides](https://github.com/ruby-amqp/bunny/tree/main/docs/guides) explain
125
+ AMQP concepts.
126
+
127
+ ## Licence
128
+
129
+ [MIT License](LICENCE)
130
+
131
+ ## Versioning policy
132
+
133
+ We follow [Semantic versioning](http://semver.org/spec/v2.0.0.html).
@@ -0,0 +1,66 @@
1
+ module PublishingPlatformMessageQueueConsumer
2
+ class Consumer
3
+ # Create a new consumer
4
+ #
5
+ # @param queue_name [String] Your queue name. This is specific to your application,
6
+ # and should already exist and have a binding
7
+ # configured via Terraform.
8
+ # @param processor [Object] An object that responds to `process`
9
+ # @param rabbitmq_connection [Object] A Bunny connection object derived from `Bunny.new`
10
+ # @param logger [Object] A Logger object for emitting errors (to stderr by default)
11
+ # @param worker_threads [Number] Size of the worker thread pool. Defaults to 1.
12
+ # @param prefetch [Number] Maximum number of unacked messages to allow on
13
+ # the channel. See
14
+ # https://www.rabbitmq.com/docs/consumer-prefetch
15
+ # Defaults to 1.
16
+ def initialize(queue_name:, processor:, rabbitmq_connection: Bunny.new, logger: Logger.new($stderr), worker_threads: 1, prefetch: 1)
17
+ @queue_name = queue_name
18
+ @processor = processor
19
+ @rabbitmq_connection = rabbitmq_connection
20
+ @logger = logger
21
+ @worker_threads = worker_threads
22
+ @prefetch = prefetch
23
+ end
24
+
25
+ def run(subscribe_opts: {})
26
+ @rabbitmq_connection.start
27
+
28
+ subscribe_opts = { block: true, manual_ack: true }.merge(subscribe_opts)
29
+ queue.subscribe(subscribe_opts) do |delivery_info, headers, payload|
30
+ message = Message.new(payload, headers, delivery_info)
31
+ message_consumer.process(message)
32
+ rescue StandardError => e
33
+ PublishingPlatformError.notify(e) if defined?(PublishingPlatformError)
34
+ @logger.error "Uncaught exception in processor: \n\n #{e.class}: #{e.message}\n\n#{e.backtrace.join("\n")}"
35
+ exit(1) # Ensure rabbitmq requeues outstanding messages
36
+ end
37
+ rescue SignalException => e
38
+ PublishingPlatformError.notify(e) if defined?(PublishingPlatformError) && e.message != "SIGTERM"
39
+
40
+ exit
41
+ end
42
+
43
+ private
44
+
45
+ def message_consumer
46
+ @message_consumer ||= MessageConsumer.new(
47
+ processors: [
48
+ HeartbeatProcessor.new,
49
+ JSONProcessor.new,
50
+ @processor,
51
+ ],
52
+ )
53
+ end
54
+
55
+ def queue
56
+ @queue ||= begin
57
+ channel.prefetch(@prefetch)
58
+ channel.queue(@queue_name, no_declare: true)
59
+ end
60
+ end
61
+
62
+ def channel
63
+ @channel ||= @rabbitmq_connection.create_channel(nil, @worker_threads)
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,13 @@
1
+ module PublishingPlatformMessageQueueConsumer
2
+ class HeartbeatProcessor
3
+ def process(message)
4
+ # Ignore heartbeat messages
5
+ if message.headers.content_type == "application/x-heartbeat"
6
+ message.ack
7
+ return false
8
+ end
9
+
10
+ true
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,17 @@
1
+ module PublishingPlatformMessageQueueConsumer
2
+ class JSONProcessor
3
+ JSON_FORMAT = "application/json".freeze
4
+
5
+ def process(message)
6
+ if message.headers.content_type == JSON_FORMAT
7
+ message.payload = JSON.parse(message.payload)
8
+ end
9
+
10
+ true
11
+ rescue JSON::ParserError => e
12
+ PublishingPlatformError.notify(e) if defined?(PublishingPlatformError)
13
+ message.discard
14
+ false
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,28 @@
1
+ module PublishingPlatformMessageQueueConsumer
2
+ # Client code will receive an instance of this
3
+ class Message
4
+ attr_accessor :delivery_info, :headers, :payload, :status
5
+
6
+ def initialize(payload, headers, delivery_info)
7
+ @payload = payload
8
+ @headers = headers
9
+ @delivery_info = delivery_info
10
+ @status = :status
11
+ end
12
+
13
+ def ack
14
+ @delivery_info.channel.ack(@delivery_info.delivery_tag)
15
+ @status = :acked
16
+ end
17
+
18
+ def retry
19
+ @delivery_info.channel.reject(@delivery_info.delivery_tag, true)
20
+ @status = :retried
21
+ end
22
+
23
+ def discard
24
+ @delivery_info.channel.reject(@delivery_info.delivery_tag, false)
25
+ @status = :discarded
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,13 @@
1
+ module PublishingPlatformMessageQueueConsumer
2
+ class MessageConsumer
3
+ def initialize(processors:)
4
+ @processors = processors
5
+ end
6
+
7
+ def process(message)
8
+ @processors.each do |processor|
9
+ break unless processor.process(message)
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,27 @@
1
+ require "ostruct"
2
+
3
+ module PublishingPlatformMessageQueueConsumer
4
+ class MockMessage < Message
5
+ attr_reader :acked, :retried, :discarded, :payload, :header, :delivery_info
6
+
7
+ alias_method :acked?, :acked
8
+ alias_method :discarded?, :discarded
9
+ alias_method :retried?, :retried
10
+
11
+ def initialize(payload = {}, headers = {}, delivery_info = {})
12
+ super(payload, OpenStruct.new(headers), OpenStruct.new(delivery_info))
13
+ end
14
+
15
+ def ack
16
+ @acked = true
17
+ end
18
+
19
+ def retry
20
+ @retried = true
21
+ end
22
+
23
+ def discard
24
+ @discarded = true
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,11 @@
1
+ if defined?(RSpec)
2
+ RSpec.shared_examples "a message queue processor" do
3
+ it "implements #process" do
4
+ expect(subject).to respond_to(:process)
5
+ end
6
+
7
+ it "accepts 1 argument for #process" do
8
+ expect(subject.method(:process).arity).to eq(1)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,2 @@
1
+ require "publishing_platform_message_queue_consumer/test_helpers/shared_examples"
2
+ require "publishing_platform_message_queue_consumer/test_helpers/mock_message"
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PublishingPlatformMessageQueueConsumer
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,9 @@
1
+ require "bunny"
2
+ require "json"
3
+
4
+ require "publishing_platform_message_queue_consumer/version"
5
+ require "publishing_platform_message_queue_consumer/heartbeat_processor"
6
+ require "publishing_platform_message_queue_consumer/json_processor"
7
+ require "publishing_platform_message_queue_consumer/message"
8
+ require "publishing_platform_message_queue_consumer/message_consumer"
9
+ require "publishing_platform_message_queue_consumer/consumer"
metadata ADDED
@@ -0,0 +1,106 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: publishing_platform_message_queue_consumer
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Publishing Platform
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: bunny
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '2.24'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '2.24'
26
+ - !ruby/object:Gem::Dependency
27
+ name: bunny-mock
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: publishing_platform_rubocop
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: simplecov
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ description: Avoid writing boilerplate code in order to consume messages from an AMQP
69
+ message queue. Plug in queue configuration, and how to process each message.
70
+ executables: []
71
+ extensions: []
72
+ extra_rdoc_files: []
73
+ files:
74
+ - LICENSE
75
+ - README.md
76
+ - lib/publishing_platform_message_queue_consumer.rb
77
+ - lib/publishing_platform_message_queue_consumer/consumer.rb
78
+ - lib/publishing_platform_message_queue_consumer/heartbeat_processor.rb
79
+ - lib/publishing_platform_message_queue_consumer/json_processor.rb
80
+ - lib/publishing_platform_message_queue_consumer/message.rb
81
+ - lib/publishing_platform_message_queue_consumer/message_consumer.rb
82
+ - lib/publishing_platform_message_queue_consumer/test_helpers.rb
83
+ - lib/publishing_platform_message_queue_consumer/test_helpers/mock_message.rb
84
+ - lib/publishing_platform_message_queue_consumer/test_helpers/shared_examples.rb
85
+ - lib/publishing_platform_message_queue_consumer/version.rb
86
+ licenses:
87
+ - MIT
88
+ metadata: {}
89
+ rdoc_options: []
90
+ require_paths:
91
+ - lib
92
+ required_ruby_version: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '3.1'
97
+ required_rubygems_version: !ruby/object:Gem::Requirement
98
+ requirements:
99
+ - - ">="
100
+ - !ruby/object:Gem::Version
101
+ version: '0'
102
+ requirements: []
103
+ rubygems_version: 3.6.9
104
+ specification_version: 4
105
+ summary: AMQP message queue consumption with Publishing Platform conventions
106
+ test_files: []