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 +7 -0
- data/LICENSE +22 -0
- data/README.md +133 -0
- data/lib/publishing_platform_message_queue_consumer/consumer.rb +66 -0
- data/lib/publishing_platform_message_queue_consumer/heartbeat_processor.rb +13 -0
- data/lib/publishing_platform_message_queue_consumer/json_processor.rb +17 -0
- data/lib/publishing_platform_message_queue_consumer/message.rb +28 -0
- data/lib/publishing_platform_message_queue_consumer/message_consumer.rb +13 -0
- data/lib/publishing_platform_message_queue_consumer/test_helpers/mock_message.rb +27 -0
- data/lib/publishing_platform_message_queue_consumer/test_helpers/shared_examples.rb +11 -0
- data/lib/publishing_platform_message_queue_consumer/test_helpers.rb +2 -0
- data/lib/publishing_platform_message_queue_consumer/version.rb +5 -0
- data/lib/publishing_platform_message_queue_consumer.rb +9 -0
- metadata +106 -0
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,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,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: []
|