osbourne 0.1.3

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.
Files changed (35) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +674 -0
  3. data/README.md +106 -0
  4. data/Rakefile +30 -0
  5. data/bin/cli/base.rb +41 -0
  6. data/bin/osbourne +33 -0
  7. data/lib/generators/osbourne/install/install_generator.rb +14 -0
  8. data/lib/generators/osbourne/install/templates/osbourne_initializer_template.template +30 -0
  9. data/lib/generators/osbourne/install/templates/osbourne_yaml_template.template +15 -0
  10. data/lib/generators/osbourne/worker/USAGE +11 -0
  11. data/lib/generators/osbourne/worker/templates/worker_template.template +35 -0
  12. data/lib/generators/osbourne/worker/worker_generator.rb +20 -0
  13. data/lib/osbourne.rb +43 -0
  14. data/lib/osbourne/config/file_loader.rb +22 -0
  15. data/lib/osbourne/config/shared_configs.rb +37 -0
  16. data/lib/osbourne/existing_subscriptions.rb +40 -0
  17. data/lib/osbourne/launcher.rb +60 -0
  18. data/lib/osbourne/locks/base.rb +69 -0
  19. data/lib/osbourne/locks/memory.rb +69 -0
  20. data/lib/osbourne/locks/noop.rb +25 -0
  21. data/lib/osbourne/locks/redis.rb +56 -0
  22. data/lib/osbourne/message.rb +55 -0
  23. data/lib/osbourne/poller.rb +0 -0
  24. data/lib/osbourne/queue.rb +43 -0
  25. data/lib/osbourne/railtie.rb +20 -0
  26. data/lib/osbourne/runner.rb +86 -0
  27. data/lib/osbourne/services/queue_provisioner.rb +14 -0
  28. data/lib/osbourne/services/sns.rb +17 -0
  29. data/lib/osbourne/services/sqs.rb +17 -0
  30. data/lib/osbourne/subscription.rb +36 -0
  31. data/lib/osbourne/topic.rb +34 -0
  32. data/lib/osbourne/version.rb +5 -0
  33. data/lib/osbourne/worker_base.rb +91 -0
  34. data/lib/tasks/message_plumber_tasks.rake +6 -0
  35. metadata +348 -0
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ $stdout.sync = true
4
+
5
+ require "singleton"
6
+
7
+ require "osbourne"
8
+
9
+ module Osbourne
10
+ class Shutdown < Interrupt; end # rubocop:disable Lint/InheritException
11
+
12
+ class Runner
13
+ include Singleton
14
+
15
+ # rubocop:disable Metrics/MethodLength
16
+ def run
17
+ self_read, self_write = IO.pipe
18
+
19
+ %w[INT TERM USR1 TSTP TTIN].each do |sig|
20
+ begin
21
+ trap sig do
22
+ self_write.puts(sig)
23
+ end
24
+ rescue ArgumentError
25
+ puts "Signal #{sig} not supported"
26
+ end
27
+ end
28
+
29
+ @launcher = Osbourne::Launcher.new
30
+
31
+ begin
32
+ @launcher.start!
33
+
34
+ while readable_io = IO.select([self_read]) # rubocop:disable Lint/AssignmentInCondition
35
+ signal = readable_io.first[0].gets.strip
36
+ handle_signal(signal)
37
+ end
38
+ @launcher.threads.map(&:join)
39
+ rescue Interrupt
40
+ @launcher.stop!
41
+ exit 0
42
+ end
43
+ end
44
+ # rubocop:enable Metrics/MethodLength
45
+
46
+ private
47
+
48
+ def execute_soft_shutdown
49
+ Osbourne.logger.info { "Received USR1, will soft shutdown down" }
50
+
51
+ @launcher.stop
52
+ exit 0
53
+ end
54
+
55
+ def execute_terminal_stop
56
+ Osbourne.logger.info { "Received TSTP, will stop accepting new work" }
57
+
58
+ @launcher.stop!
59
+ end
60
+
61
+ def print_threads_backtrace
62
+ Thread.list.each do |thread|
63
+ Osbourne.logger.info { "Thread TID-#{thread.object_id.to_s(36)} #{thread['label']}" }
64
+ if thread.backtrace
65
+ Osbourne.logger.info { thread.backtrace.join("\n") }
66
+ else
67
+ Osbourne.logger.info { "<no backtrace available>" }
68
+ end
69
+ end
70
+ end
71
+
72
+ def handle_signal(sig)
73
+ Osbourne.logger.debug "Got #{sig} signal"
74
+
75
+ case sig
76
+ when "USR1" then execute_soft_shutdown
77
+ when "TTIN" then print_threads_backtrace
78
+ when "TSTP" then execute_terminal_stop
79
+ when "TERM", "INT"
80
+ Osbourne.logger.info { "Received #{sig}, will shutdown" }
81
+
82
+ raise Interrupt
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Osbourne
4
+ module Services
5
+ module QueueProvisioner
6
+ def provision_worker_queues
7
+ Osbourne.logger.info "Workers found: #{Osbourne::WorkerBase.descendants.map(&:name).join(', ')}"
8
+ Osbourne.logger.info "Provisioning queues for all workers"
9
+
10
+ Osbourne::WorkerBase.descendants.each(&:provision)
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "aws-sdk-sns"
4
+
5
+ module Osbourne
6
+ module Services
7
+ module SNS
8
+ def sns
9
+ @sns ||= Osbourne.sns_client
10
+ end
11
+
12
+ def sns=(client)
13
+ Osbourne.sns_client = client
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "aws-sdk-sqs"
4
+
5
+ module Osbourne
6
+ module Services
7
+ module SQS
8
+ def sqs
9
+ @sqs ||= Osbourne.sqs_client
10
+ end
11
+
12
+ def sqs=(client)
13
+ Osbourne.sqs_client = client
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Osbourne
4
+ class Subscription
5
+ include Services::SNS
6
+ attr_reader :topic, :queue
7
+ def initialize(topic, queue)
8
+ @topic = topic
9
+ @queue = queue
10
+ arn
11
+ end
12
+
13
+ def arn
14
+ @arn ||= subscribe
15
+ end
16
+
17
+ private
18
+
19
+ def subscribe # rubocop:disable Metrics/AbcSize
20
+ Osbourne.logger.info("Checking subscription for #{queue.name} to #{topic.name}")
21
+ return if Osbourne.existing_subscriptions_for(topic).include? queue.arn
22
+
23
+ handled = Osbourne.lock.try_with_lock("osbourne_sub_lock_#{topic.name}") do
24
+ Osbourne.logger.info("Subscribing #{queue.name} to #{topic.name}")
25
+ @arn = sns.subscribe(topic_arn: topic.arn, protocol: "sqs", endpoint: queue.arn).subscription_arn
26
+ Osbourne.clear_subscriptions_for(topic)
27
+ end
28
+ if handled
29
+ @arn
30
+ else
31
+ sleep(3)
32
+ subscribe
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Osbourne
4
+ class Topic
5
+ include Services::SNS
6
+ attr_reader :name
7
+ def initialize(name)
8
+ @name = name
9
+ arn
10
+ end
11
+
12
+ def arn
13
+ @arn ||= ensure_topic
14
+ end
15
+
16
+ def publish(message)
17
+ Osbourne.logger.info "[PUB] TOPIC: `#{name}` MESSAGE: `#{message}`"
18
+ sns.publish(topic_arn: arn, message: message.is_a?(String) ? message : message.to_json)
19
+ end
20
+
21
+ private
22
+
23
+ def ensure_topic
24
+ Osbourne.logger.debug "Ensuring topic `#{name}` exists"
25
+ Osbourne.cache.fetch("osbourne_existing_topic_arn_for_#{name}") do
26
+ sns.create_topic(name: name).topic_arn
27
+ end
28
+ end
29
+
30
+ def subscriptions_cache_key
31
+ "existing_sqs_subscriptions_for_#{name}"
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Osbourne
4
+ VERSION = "0.1.3"
5
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Osbourne
4
+ class WorkerBase
5
+ @config = {}
6
+
7
+ def config
8
+ self.class.config
9
+ end
10
+
11
+ def process(_message)
12
+ raise NotImplementedError, "#{self} must implement class method `process`"
13
+ end
14
+
15
+ def config=(config)
16
+ self.class.config = config
17
+ end
18
+
19
+ def queue
20
+ self.class.queue
21
+ end
22
+
23
+ def polling_queue
24
+ self.class.polling_queue
25
+ end
26
+
27
+ class << self
28
+ attr_accessor :config, :subscriptions, :topics, :queue
29
+
30
+ def descendants
31
+ ObjectSpace.each_object(Class).select {|klass| klass < self }
32
+ end
33
+
34
+ def provision
35
+ register
36
+ register_dead_letter_queue
37
+ end
38
+
39
+ def max_retry_count
40
+ Osbourne.max_retry_count
41
+ end
42
+
43
+ def dead_letter_queue_name
44
+ "#{config[:queue_name]}-dead-letter"
45
+ end
46
+
47
+ def dead_letter_queue
48
+ @dead_letter_queue ||= Queue.new(dead_letter_queue_name)
49
+ end
50
+
51
+ def queue_name
52
+ default_queue_name
53
+ end
54
+
55
+ def polling_queue
56
+ @polling_queue ||= Aws::SQS::Queue.new(queue.url, client: Osbourne.sqs_client)
57
+ end
58
+ end
59
+
60
+ class << self
61
+ private
62
+
63
+ def register_dead_letter_queue
64
+ return unless Osbourne.dead_letter
65
+
66
+ Osbourne.logger.info "#{self.class.name} dead letter queue: arn: [#{dead_letter_queue.arn}], max retries: #{max_retry_count}" # rubocop:disable Metrics/LineLength
67
+ queue.redrive(max_retry_count, dead_letter_queue.arn)
68
+ end
69
+
70
+ def register
71
+ Osbourne.logger.info "#{self.class.name} subscriptions: Topics: [#{config[:topic_names].join(', ')}], Queue: [#{config[:queue_name]}]" # rubocop:disable Metrics/LineLength
72
+ self.topics = config[:topic_names].map {|tn| Topic.new(tn) }
73
+ self.queue = Queue.new(config[:queue_name])
74
+ self.subscriptions = topics.map {|t| Subscription.new(t, queue) }
75
+ end
76
+
77
+ def default_queue_name
78
+ "#{name.underscore}_queue"
79
+ end
80
+
81
+ def worker_config(topics: [], max_batch_size: 10, max_wait: 10)
82
+ self.config = {
83
+ topic_names: Array(topics),
84
+ queue_name: queue_name,
85
+ max_batch_size: max_batch_size,
86
+ max_wait: max_wait
87
+ }
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # desc "Explaining what the task does"
4
+ # task :osbourne do
5
+ # # Task goes here
6
+ # end
metadata ADDED
@@ -0,0 +1,348 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: osbourne
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.3
5
+ platform: ruby
6
+ authors:
7
+ - Steve Allen
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2018-10-09 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: aws-sdk-core
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '3'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '3'
27
+ - !ruby/object:Gem::Dependency
28
+ name: aws-sdk-sns
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1'
41
+ - !ruby/object:Gem::Dependency
42
+ name: aws-sdk-sqs
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rails
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '5'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '5'
69
+ - !ruby/object:Gem::Dependency
70
+ name: thor
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: bundler
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '1.8'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '1.8'
97
+ - !ruby/object:Gem::Dependency
98
+ name: connection_pool
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '2'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '2'
111
+ - !ruby/object:Gem::Dependency
112
+ name: guard
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '2'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: '2'
125
+ - !ruby/object:Gem::Dependency
126
+ name: guard-bundler
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - "~>"
130
+ - !ruby/object:Gem::Version
131
+ version: '2.1'
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - "~>"
137
+ - !ruby/object:Gem::Version
138
+ version: '2.1'
139
+ - !ruby/object:Gem::Dependency
140
+ name: guard-rspec
141
+ requirement: !ruby/object:Gem::Requirement
142
+ requirements:
143
+ - - "~>"
144
+ - !ruby/object:Gem::Version
145
+ version: '4.7'
146
+ type: :development
147
+ prerelease: false
148
+ version_requirements: !ruby/object:Gem::Requirement
149
+ requirements:
150
+ - - "~>"
151
+ - !ruby/object:Gem::Version
152
+ version: '4.7'
153
+ - !ruby/object:Gem::Dependency
154
+ name: mock_redis
155
+ requirement: !ruby/object:Gem::Requirement
156
+ requirements:
157
+ - - "~>"
158
+ - !ruby/object:Gem::Version
159
+ version: '0'
160
+ type: :development
161
+ prerelease: false
162
+ version_requirements: !ruby/object:Gem::Requirement
163
+ requirements:
164
+ - - "~>"
165
+ - !ruby/object:Gem::Version
166
+ version: '0'
167
+ - !ruby/object:Gem::Dependency
168
+ name: pry-byebug
169
+ requirement: !ruby/object:Gem::Requirement
170
+ requirements:
171
+ - - "~>"
172
+ - !ruby/object:Gem::Version
173
+ version: '3.6'
174
+ type: :development
175
+ prerelease: false
176
+ version_requirements: !ruby/object:Gem::Requirement
177
+ requirements:
178
+ - - "~>"
179
+ - !ruby/object:Gem::Version
180
+ version: '3.6'
181
+ - !ruby/object:Gem::Dependency
182
+ name: rake
183
+ requirement: !ruby/object:Gem::Requirement
184
+ requirements:
185
+ - - "~>"
186
+ - !ruby/object:Gem::Version
187
+ version: '10.0'
188
+ type: :development
189
+ prerelease: false
190
+ version_requirements: !ruby/object:Gem::Requirement
191
+ requirements:
192
+ - - "~>"
193
+ - !ruby/object:Gem::Version
194
+ version: '10.0'
195
+ - !ruby/object:Gem::Dependency
196
+ name: redis
197
+ requirement: !ruby/object:Gem::Requirement
198
+ requirements:
199
+ - - "~>"
200
+ - !ruby/object:Gem::Version
201
+ version: '4'
202
+ type: :development
203
+ prerelease: false
204
+ version_requirements: !ruby/object:Gem::Requirement
205
+ requirements:
206
+ - - "~>"
207
+ - !ruby/object:Gem::Version
208
+ version: '4'
209
+ - !ruby/object:Gem::Dependency
210
+ name: rspec
211
+ requirement: !ruby/object:Gem::Requirement
212
+ requirements:
213
+ - - "~>"
214
+ - !ruby/object:Gem::Version
215
+ version: '3'
216
+ type: :development
217
+ prerelease: false
218
+ version_requirements: !ruby/object:Gem::Requirement
219
+ requirements:
220
+ - - "~>"
221
+ - !ruby/object:Gem::Version
222
+ version: '3'
223
+ - !ruby/object:Gem::Dependency
224
+ name: rubocop
225
+ requirement: !ruby/object:Gem::Requirement
226
+ requirements:
227
+ - - "~>"
228
+ - !ruby/object:Gem::Version
229
+ version: '0'
230
+ type: :development
231
+ prerelease: false
232
+ version_requirements: !ruby/object:Gem::Requirement
233
+ requirements:
234
+ - - "~>"
235
+ - !ruby/object:Gem::Version
236
+ version: '0'
237
+ - !ruby/object:Gem::Dependency
238
+ name: rubocop-rspec
239
+ requirement: !ruby/object:Gem::Requirement
240
+ requirements:
241
+ - - "~>"
242
+ - !ruby/object:Gem::Version
243
+ version: '1'
244
+ type: :development
245
+ prerelease: false
246
+ version_requirements: !ruby/object:Gem::Requirement
247
+ requirements:
248
+ - - "~>"
249
+ - !ruby/object:Gem::Version
250
+ version: '1'
251
+ - !ruby/object:Gem::Dependency
252
+ name: simplecov
253
+ requirement: !ruby/object:Gem::Requirement
254
+ requirements:
255
+ - - "~>"
256
+ - !ruby/object:Gem::Version
257
+ version: '0.16'
258
+ type: :development
259
+ prerelease: false
260
+ version_requirements: !ruby/object:Gem::Requirement
261
+ requirements:
262
+ - - "~>"
263
+ - !ruby/object:Gem::Version
264
+ version: '0.16'
265
+ - !ruby/object:Gem::Dependency
266
+ name: simplecov-console
267
+ requirement: !ruby/object:Gem::Requirement
268
+ requirements:
269
+ - - "~>"
270
+ - !ruby/object:Gem::Version
271
+ version: '0.4'
272
+ type: :development
273
+ prerelease: false
274
+ version_requirements: !ruby/object:Gem::Requirement
275
+ requirements:
276
+ - - "~>"
277
+ - !ruby/object:Gem::Version
278
+ version: '0.4'
279
+ description: |
280
+ This is a simple implementation of the fan-out pubsub pattern for Rails, using SQS & SNS as the message broker.
281
+ Includes a generator & runner for workers, as well as built-in provisioning for topics, subscriptions,
282
+ qeues, and dead-letter queues
283
+ email:
284
+ - sallen@amberstyle.ca
285
+ executables:
286
+ - osbourne
287
+ extensions: []
288
+ extra_rdoc_files: []
289
+ files:
290
+ - LICENSE
291
+ - README.md
292
+ - Rakefile
293
+ - bin/cli/base.rb
294
+ - bin/osbourne
295
+ - lib/generators/osbourne/install/install_generator.rb
296
+ - lib/generators/osbourne/install/templates/osbourne_initializer_template.template
297
+ - lib/generators/osbourne/install/templates/osbourne_yaml_template.template
298
+ - lib/generators/osbourne/worker/USAGE
299
+ - lib/generators/osbourne/worker/templates/worker_template.template
300
+ - lib/generators/osbourne/worker/worker_generator.rb
301
+ - lib/osbourne.rb
302
+ - lib/osbourne/config/file_loader.rb
303
+ - lib/osbourne/config/shared_configs.rb
304
+ - lib/osbourne/existing_subscriptions.rb
305
+ - lib/osbourne/launcher.rb
306
+ - lib/osbourne/locks/base.rb
307
+ - lib/osbourne/locks/memory.rb
308
+ - lib/osbourne/locks/noop.rb
309
+ - lib/osbourne/locks/redis.rb
310
+ - lib/osbourne/message.rb
311
+ - lib/osbourne/poller.rb
312
+ - lib/osbourne/queue.rb
313
+ - lib/osbourne/railtie.rb
314
+ - lib/osbourne/runner.rb
315
+ - lib/osbourne/services/queue_provisioner.rb
316
+ - lib/osbourne/services/sns.rb
317
+ - lib/osbourne/services/sqs.rb
318
+ - lib/osbourne/subscription.rb
319
+ - lib/osbourne/topic.rb
320
+ - lib/osbourne/version.rb
321
+ - lib/osbourne/worker_base.rb
322
+ - lib/tasks/message_plumber_tasks.rake
323
+ homepage: https://github.com/stevenallen05/osbourne
324
+ licenses:
325
+ - GPL-3.0
326
+ metadata: {}
327
+ post_install_message:
328
+ rdoc_options: []
329
+ require_paths:
330
+ - lib
331
+ required_ruby_version: !ruby/object:Gem::Requirement
332
+ requirements:
333
+ - - ">="
334
+ - !ruby/object:Gem::Version
335
+ version: '2.3'
336
+ required_rubygems_version: !ruby/object:Gem::Requirement
337
+ requirements:
338
+ - - ">="
339
+ - !ruby/object:Gem::Version
340
+ version: '0'
341
+ requirements: []
342
+ rubyforge_project:
343
+ rubygems_version: 2.7.6
344
+ signing_key:
345
+ specification_version: 4
346
+ summary: A simple implementation of the fan-out pubsub pattern using SQS & SNS for
347
+ Rails
348
+ test_files: []