osbourne 0.2.2 → 1.0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 699bb698e3dae6272c3038bdc052597e0a6677e8f380bc1f64c12ae1c18c63a0
4
- data.tar.gz: ca0bf27de44bff4a2e96755d0c041a73bbb75bc941c7166c44dfdd144630d3e1
3
+ metadata.gz: 30bc10e5c04691de985ff77f554ac23596052bbdd70de1d642f875378065f7e8
4
+ data.tar.gz: aebe87fe5fff351c0ab9f9add37508b81f1f1e5147b06e8c41cfd555db216c23
5
5
  SHA512:
6
- metadata.gz: b464098af275a72c4aa7a2ba600b1bdb709485f4ddaac98462f6513b8c2ae6930c9e8b8bdb478cf58618e39f1d224070957af61fa4633a4032e53ea0e10c3c9e
7
- data.tar.gz: 32b5e08de9d525e95408525e0d9c0723383a33d3ac8aa688e323af9c49db1bbb15023e2fde9de099750e10a166770d58e3aba8826392702f61355bfd715bb308
6
+ metadata.gz: 93a0d8128556f2a77abe7c82a31fdd73d41c2647c6021a76d1aa8a6bfdceb86c32d7a361d0a14d01597b75fd84644090038bedc7f98e0dbad28fcedadd54948e
7
+ data.tar.gz: 4fe3bea017685121b90e49289aee1998a25b606ec1ab0b01a7519cecffd8193e922cfe99bcb47dd402f1661defe1e073d89317def80a334a177da28df509961f
data/README.md CHANGED
@@ -148,7 +148,7 @@ This will generate a `WorkerNameWorker` in `app/workers/worker_name_worker.rb`,
148
148
 
149
149
  There is some configuration options available within the generated worker. See comments in the worker for options.
150
150
 
151
- SNS messages broadcast through an SQS queue will have some layers of envelop wrappings around them. The `message` object passed into the worker's `#perform` method contains some helpers to make parsing this easier. `#parsed_body` is the most important one, as it contains the actual string of the message that was originally broadcast.
151
+ SNS messages broadcast through an SQS queue will have some layers of envelop wrappings around them. The `message` object passed into the worker's `#perform` method contains some helpers to make parsing this easier. `#message_body` is the most important one, as it contains the actual string of the message that was originally broadcast.
152
152
 
153
153
  ### Running workers
154
154
 
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- Dir[File.expand_path("app/workers/**/*.rb")].each {|f| require f }
4
-
5
3
  Osbourne.configure do |config|
6
4
  # Defaults to a null cache. Uncomment to use the Rails cache,
7
5
  # or substitute any rails-cache compatible adapter of your choice
@@ -2,33 +2,23 @@
2
2
 
3
3
  class <%= name.singularize.camelcase %>Worker < Osbourne::WorkerBase
4
4
 
5
- worker_config topics: %w[<%= topic.join(" ") %>] #, max_batch_size: 10, max_wait: 10
5
+ worker_config topics: %w[<%= topic.join(" ") %>]
6
+ # Other available onfig options and their defaults:
7
+ # max_batch_size: 10
8
+ # max_wait: 10
9
+ # threads: Osbourne.threads_per_worker
10
+ # queue_name: <%= name %>_queue
11
+ # dead_letter_queue: true
12
+ # max_retry_count: Osbourne.max_retry_count
6
13
 
7
14
  def process(message)
8
- puts message.parsed_body # If the message came from a SNS broadcast,
9
- # as opposed to a direct SQS message, it will come from here
10
- puts message.topic # The topic this message was published to.
11
- # Useful if this worker subscribes to more than one topic
12
- puts message.id # The UUID for this message. Useful for validating if this is
13
- # the first time it has been processed
15
+ puts message.sns? # Was this message broadcast via SNS?
16
+ puts message.raw_body # The only way to access a message that wasn't sent via SNS
17
+ puts message.message_body # If the message came from a SNS broadcast,
18
+ # as opposed to a direct SQS message, it will come from here
19
+ puts message.topic # The topic this message was published to.
20
+ # Useful if this worker subscribes to more than one topic
21
+ puts message.id # The UUID for this message. Useful for validating if this is
22
+ # the first time it has been processed
14
23
  end
15
-
16
- # class << self
17
- # # override this to set how many times a message will be retried before
18
- # # being redirected to the dead letter queue (if enabled)
19
- # def max_retry_count
20
- # end
21
-
22
- # # override this to set the worker's dead letter queue name
23
- # def dead_letter_queue_name
24
- # end
25
-
26
- # # override this to `false` to disable the dead letter queu for this worker
27
- # def dead_letter_queue
28
- # end
29
-
30
- # # override this to set the worker queue name
31
- # def queue_name
32
- # end
33
- # end
34
24
  end
data/lib/osbourne.rb CHANGED
@@ -22,7 +22,7 @@ module Osbourne
22
22
  include Osbourne::Config::SharedConfigs
23
23
  include Osbourne::Services::QueueProvisioner
24
24
  include Osbourne::ExistingSubscriptions
25
- attr_writer :sns_client, :sqs_client
25
+ # attr_writer :sns_client, :sqs_client
26
26
 
27
27
  def sns_client
28
28
  return if Osbourne.test_mode?
@@ -36,6 +36,10 @@ module Osbourne
36
36
  @sqs_client ||= Aws::SQS::Client.new(Osbourne.config.sqs_config)
37
37
  end
38
38
 
39
+ attr_writer :sns_client
40
+
41
+ attr_writer :sqs_client
42
+
39
43
  def publish(topic, message)
40
44
  Topic.new(topic).publish(message)
41
45
  end
@@ -13,8 +13,8 @@ module Osbourne
13
13
  base_opts = YAML.safe_load(ERB.new(IO.read(cfile)).result) || {}
14
14
  env_opts = base_opts[environment] || {}
15
15
 
16
- Osbourne.config.sns_config = env_opts["publisher"].symbolize_keys || {}
17
- Osbourne.config.sqs_config = env_opts["subscriber"].symbolize_keys || {}
16
+ Osbourne.config.sns_config = env_opts["publisher"]&.symbolize_keys || {}
17
+ Osbourne.config.sqs_config = env_opts["subscriber"]&.symbolize_keys || {}
18
18
  true
19
19
  end
20
20
 
@@ -34,15 +34,19 @@ module Osbourne
34
34
  end
35
35
 
36
36
  def logger
37
- config.logger ||= Logger.new(STDOUT)
37
+ config.logger ||= Logger.new("log/osbourne.log")
38
38
  end
39
39
 
40
40
  def lock
41
- config.lock || Osbourne::Locks::NOOP.new
41
+ config.lock ||= Osbourne::Locks::NOOP.new
42
42
  end
43
43
 
44
44
  def sleep_time
45
- config.sleep_time || 15
45
+ config.sleep_time ||= 15
46
+ end
47
+
48
+ def threads_per_worker
49
+ config.threads_per_worker ||= 5
46
50
  end
47
51
  end
48
52
  end
@@ -4,6 +4,7 @@ module Osbourne
4
4
  module ExistingSubscriptions
5
5
  attr_reader :existing_subscriptions
6
6
  def existing_subscriptions_for(topic)
7
+ Osbourne.cache.delete("osbourne_existng_subs_for_#{topic.name}")
7
8
  Osbourne.cache.fetch("osbourne_existng_subs_for_#{topic.name}") do
8
9
  results = []
9
10
  handled = Osbourne.lock.try_with_lock("osbourne_lock_subs_for_#{topic.name}") do
@@ -14,8 +15,6 @@ module Osbourne
14
15
  sleep(0.5)
15
16
  existing_subscriptions_for(topic)
16
17
  end
17
- # @existing_subscriptions ||= {}
18
- # @existing_subscriptions[topic.name] ||=
19
18
  end
20
19
 
21
20
  def clear_subscriptions_for(topic)
@@ -10,50 +10,61 @@ module Osbourne
10
10
  def start!
11
11
  Osbourne.logger.info("Launching Osbourne workers")
12
12
  @stop = false
13
- @threads = polling_threads
14
- polling_threads.each(&:run)
13
+ @threads = global_polling_threads
14
+ end
15
+
16
+ def wait!
17
+ threads.map(&:join)
15
18
  end
16
19
 
17
20
  def stop
21
+ puts "Signal caught. Terminating workers..."
18
22
  @stop = true
19
23
  end
20
24
 
21
25
  def stop!
26
+ puts "Signal caught. Terminating workers..."
22
27
  @threads.each {|thr| Thread.kill(thr) }
23
28
  end
24
29
 
25
- def polling_threads
30
+ def global_polling_threads
26
31
  Osbourne::WorkerBase.descendants.map do |worker|
27
- Thread.new {
28
- worker_instance = worker.new
29
- loop do
30
- poll(worker_instance)
31
- sleep(Osbourne.sleep_time)
32
- break if @stop
33
- end
34
- }
32
+ Osbourne.logger.debug("Spawning thread for #{worker.name}")
33
+ Thread.new { poll(worker) }
34
+ end
35
+ end
36
+
37
+ def worker_polling_threads(worker)
38
+ my_threads = []
39
+ worker.config[:threads].times do
40
+ my_threads << Thread.new { poll(worker) }
35
41
  end
42
+ my_threads.each(&:join)
36
43
  end
37
44
 
38
45
  def poll(worker)
39
- worker.polling_queue.receive_messages(
40
- wait_time_seconds: worker.config[:max_wait],
41
- max_number_of_messages: worker.config[:max_batch_size]
42
- ).each {|msg| process(worker, Osbourne::Message.new(msg)) }
46
+ worker.polling_queue.poll(wait_time_seconds: worker.config[:max_wait_time],
47
+ max_number_of_messages: worker.config[:max_batch_size],
48
+ skip_delete: true) do |messages|
49
+ messages.map do |msg|
50
+ worker.polling_queue.delete_message(msg) if process(worker, Osbourne::Message.new(msg))
51
+ end
52
+ throw :stop_polling if @stop
53
+ end
43
54
  end
44
55
 
45
56
  private
46
57
 
47
58
  def process(worker, message)
48
- Osbourne.logger.info("[MSG] Worker: #{worker.class.name} Valid: #{message.valid?} ID: #{message.id} Body: #{message.raw_body}") # rubocop:disable Metrics/LineLength
49
- return unless message.valid?
59
+ Osbourne.logger.info("[MSG] Worker: #{worker.name} Valid: #{message.valid?} ID: #{message.id}")
60
+ return false unless message.valid? && Osbourne.lock.soft_lock(message.id)
50
61
 
51
- # hard_lock to prevent duplicate processing over the hard_lock lifespan
52
- Osbourne.lock.try_with_lock(message.id, hard_lock: true) do
53
- message.delete if worker.process(message)
62
+ Osbourne.cache.fetch(message.id, ex: 24.hours) do
63
+ worker.new.process(message).tap {|_| Osbourne.lock.unlock(message.id) }
54
64
  end
55
65
  rescue Exception => ex # rubocop:disable Lint/RescueException
56
66
  Osbourne.logger.error("[MSG ID: #{message.id}] #{ex.message}")
67
+ false
57
68
  end
58
69
  end
59
70
  end
@@ -28,9 +28,10 @@ module Osbourne
28
28
  end
29
29
 
30
30
  def try_with_lock(id, hard_lock: false)
31
- if soft_lock(id)
31
+ if lock(lock_key(id), soft_ttl)
32
32
  begin
33
33
  yield
34
+ unlock(id)
34
35
  rescue => e # rubocop:disable Style/RescueStandardError
35
36
  unlock(id)
36
37
  raise e
@@ -2,54 +2,99 @@
2
2
 
3
3
  require "osbourne"
4
4
 
5
+ ##
6
+ # Represents a single message recieved by an Osbourne listener
7
+
5
8
  module Osbourne
6
9
  class Message
7
- attr_accessor :message
10
+ attr_reader :message
8
11
  def initialize(message)
9
12
  @message = message
10
13
  end
11
14
 
15
+ ##
16
+ #
17
+ # @return [Boolean] This will be `true` if the SNS message is also JSON
12
18
  def json?
13
- !JSON.parse(parsed_content["Message"]).nil?
14
- rescue JSON::ParserError
15
- false
19
+ return false unless valid?
20
+
21
+ sns_body.is_a?(Hash)
16
22
  end
17
23
 
24
+ ##
25
+ # Does the message match the checksum? If not, the message has likely been mangled in transit
26
+ # @return [Boolean]
18
27
  def valid?
19
- message.md5_of_body == Digest::MD5.hexdigest(message.body)
28
+ @valid ||= message.md5_of_body == Digest::MD5.hexdigest(message.body)
20
29
  end
21
30
 
31
+ ##
32
+ # Osbourne has built-in message deduplication, but it's still a good idea to do some verification in a worker
33
+ #
34
+ # @return [String] The UUID of the recieved message
22
35
  def id
23
36
  message.message_id
24
37
  end
25
38
 
26
- def parsed_body
27
- JSON.parse(parsed_content["Message"])
28
- rescue JSON::ParserError
29
- parsed_content["Message"]
39
+ ##
40
+ # If the message was broadcast via SNS, the body will be available here.
41
+ #
42
+ # @return [Hash] If the message was JSON
43
+ # @return [String] If the message was not JSON
44
+ # @return [nil] If the message was not broadcast via SNS.
45
+ # @see #raw_body #raw_body for the raw body string
46
+ def message_body
47
+ sns_body
30
48
  end
31
49
 
50
+ ##
51
+ # Deletes the message from SQS to prevent retrying against another worker.
52
+ # Osbourne will automatically delete a message sent to a worker as long as the Osourbne::WorkerBase#process method returns `true`
53
+
32
54
  def delete
33
55
  message.delete
34
56
  Osbourne.logger.info "[MSG ID: #{id}] Cleared"
35
57
  end
36
58
 
59
+ ##
60
+ # The SNS topic that this message was broadcast to
61
+ # @return [String] if the message was broadcast via SNS, this will be the topic
62
+ # @return [nil] if the message was not broadcast via SNS
37
63
  def topic
38
- parsed_content["TopicArn"].split(":").last
64
+ return nil unless sns?
65
+
66
+ json_body["TopicArn"].split(":").last
39
67
  end
40
68
 
69
+ ##
70
+ # @return [String] The raw string representation of the message
41
71
  def raw_body
42
72
  message.body
43
73
  end
44
74
 
75
+ ##
76
+ # Just because a message was recieved via SQS, doesn't mean it was originally broadcast via SNS
77
+ # @return [Boolean] Was the message broadcast via SNS?
78
+ def sns?
79
+ json_body.is_a?(Hash) && (json_body.keys <=> %w[Message Type TopicArn MessageId]).zero?
80
+ end
81
+
45
82
  private
46
83
 
47
- def parsed_content
48
- @parsed_content ||= JSON.parse(message.body)
84
+ def safe_json(content)
85
+ JSON.parse(content)
86
+ rescue JSON::ParserError
87
+ false
88
+ end
89
+
90
+ def sns_body
91
+ return unless sns?
92
+
93
+ @sns_body ||= safe_json(json_body["Message"]) || json_body["Message"]
49
94
  end
50
95
 
51
- def body
52
- parsed_content["Message"]
96
+ def json_body
97
+ @json_body || safe_json(message.body)
53
98
  end
54
99
  end
55
100
  end
@@ -36,7 +36,7 @@ module Osbourne
36
36
  signal = readable_io.first[0].gets.strip
37
37
  handle_signal(signal)
38
38
  end
39
- @launcher.threads.map(&:join)
39
+ @launcher.wait!
40
40
  rescue Interrupt
41
41
  @launcher.stop!
42
42
  exit 0
@@ -4,11 +4,11 @@ module Osbourne
4
4
  module Services
5
5
  module QueueProvisioner
6
6
  def provision_worker_queues
7
+ Dir[File.expand_path("app/workers/**/*.rb")].each {|f| require f }
7
8
  return if Osbourne.test_mode?
8
9
 
9
10
  Osbourne.logger.info "Workers found: #{Osbourne::WorkerBase.descendants.map(&:name).join(', ')}"
10
11
  Osbourne.logger.info "Provisioning queues for all workers"
11
-
12
12
  Osbourne::WorkerBase.descendants.each(&:provision)
13
13
  end
14
14
  end
@@ -3,34 +3,57 @@
3
3
  module Osbourne
4
4
  class Subscription
5
5
  include Services::SNS
6
- attr_reader :topic, :queue
7
- def initialize(topic, queue)
8
- @topic = topic
6
+ include Services::SQS
7
+ attr_reader :topics, :queue
8
+ def initialize(topics, queue)
9
+ @topics = topics
9
10
  @queue = queue
10
- arn
11
- end
12
-
13
- def arn
14
- @arn ||= subscribe
11
+ subscribe_all
15
12
  end
16
13
 
17
14
  private
18
15
 
19
- def subscribe # rubocop:disable Metrics/AbcSize
16
+ def subscribe_all
17
+ topics.each {|topic| subscribe(topic) }
18
+ set_queue_policy
19
+ end
20
+
21
+ def subscribe(topic)
20
22
  Osbourne.logger.info("Checking subscription for #{queue.name} to #{topic.name}")
21
23
  return if Osbourne.existing_subscriptions_for(topic).include? queue.arn
22
24
 
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
25
+ Osbourne.logger.info("Subscribing #{queue.name} to #{topic.name}")
26
+ sns.subscribe(topic_arn: topic.arn, protocol: "sqs", endpoint: queue.arn).subscription_arn
27
+ Osbourne.clear_subscriptions_for(topic)
28
+ end
29
+
30
+ def set_queue_policy
31
+ Osbourne.logger.info("Setting policy for #{queue.name} (attributes: #{build_policy})")
32
+ sqs.set_queue_attributes(queue_url: queue.url, attributes: build_policy)
33
+ end
34
+
35
+ def build_policy
36
+ # The aws ruby SDK doesn't have a policy builder :{
37
+ {
38
+ "Policy" => {
39
+ "Version" => "2012-10-17",
40
+ "Id" => "Osbourne/#{queue.name}/SNSPolicy",
41
+ "Statement" => topics.map {|t| build_policy_statement(t) }
42
+ }.to_json
43
+ }
44
+ end
45
+
46
+ def build_policy_statement(topic)
47
+ {
48
+ "Sid" => "Sid#{topic.name}",
49
+ "Effect" => "Allow",
50
+ "Principal" => {"AWS" => "*"},
51
+ "Action" => "SQS:SendMessage",
52
+ "Resource" => queue.arn,
53
+ "Condition" => {
54
+ "ArnEquals" => {"aws:SourceArn" => topic.arn}
55
+ }
56
+ }
34
57
  end
35
58
  end
36
59
  end
@@ -26,7 +26,7 @@ module Osbourne
26
26
  return if Osbourne.test_mode?
27
27
 
28
28
  Osbourne.logger.debug "Ensuring topic `#{name}` exists"
29
- Osbourne.cache.fetch("osbourne_existing_topic_arn_for_#{name}") do
29
+ Osbourne.cache.fetch("osbourne_existing_topic_arn_for_#{name}", ex: 1.minute) do
30
30
  sns.create_topic(name: name).topic_arn
31
31
  end
32
32
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Osbourne
4
- VERSION = "0.2.2"
4
+ VERSION = "1.0.0"
5
5
  end
@@ -21,7 +21,7 @@ module Osbourne
21
21
  end
22
22
 
23
23
  def polling_queue
24
- self.class.polling_queue
24
+ @polling_queue ||= self.class.polling_queue
25
25
  end
26
26
 
27
27
  class << self
@@ -36,24 +36,14 @@ module Osbourne
36
36
  register_dead_letter_queue
37
37
  end
38
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
39
  def dead_letter_queue
48
- @dead_letter_queue ||= Queue.new(dead_letter_queue_name)
49
- end
40
+ return unless config[:dead_letter]
50
41
 
51
- def queue_name
52
- default_queue_name
42
+ @dead_letter_queue ||= Queue.new(dead_letter_queue_name)
53
43
  end
54
44
 
55
45
  def polling_queue
56
- @polling_queue ||= Aws::SQS::Queue.new(queue.url, client: Osbourne.sqs_client)
46
+ Aws::SQS::QueuePoller.new(queue.url, client: Osbourne.sqs_client)
57
47
  end
58
48
  end
59
49
 
@@ -61,31 +51,46 @@ module Osbourne
61
51
  private
62
52
 
63
53
  def register_dead_letter_queue
64
- return unless Osbourne.dead_letter
54
+ return unless config[:dead_letter]
65
55
 
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)
56
+ Osbourne.logger.info "#{self.class.name} dead letter queue: arn: [#{dead_letter_queue.arn}], max retries: #{config[:max_retry_count]}" # rubocop:disable Metrics/LineLength
57
+ queue.redrive(config[:max_retry_count], dead_letter_queue.arn)
68
58
  end
69
59
 
70
60
  def register
71
61
  Osbourne.logger.info "#{self.class.name} subscriptions: Topics: [#{config[:topic_names].join(', ')}], Queue: [#{config[:queue_name]}]" # rubocop:disable Metrics/LineLength
72
62
  self.topics = config[:topic_names].map {|tn| Topic.new(tn) }
73
63
  self.queue = Queue.new(config[:queue_name])
74
- self.subscriptions = topics.map {|t| Subscription.new(t, queue) }
64
+ self.subscriptions = Subscription.new(topics, queue)
75
65
  end
76
66
 
77
67
  def default_queue_name
78
68
  "#{name.underscore}_queue"
79
69
  end
80
70
 
81
- def worker_config(topics: [], max_batch_size: 10, max_wait: 10)
71
+ def dead_letter_queue_name
72
+ "#{config[:queue_name]}-dead-letter"
73
+ end
74
+
75
+ # rubocop:disable Metrics/ParameterLists
76
+ def worker_config(topics: [],
77
+ max_batch_size: 10,
78
+ max_wait: 10,
79
+ threads: Osbourne.threads_per_worker,
80
+ queue_name: default_queue_name,
81
+ dead_letter_queue: true,
82
+ max_retry_count: Osbourne.max_retry_count)
82
83
  self.config = {
83
- topic_names: Array(topics),
84
- queue_name: queue_name,
85
- max_batch_size: max_batch_size,
86
- max_wait: max_wait
84
+ topic_names: Array(topics),
85
+ queue_name: queue_name,
86
+ max_batch_size: max_batch_size,
87
+ max_wait: max_wait,
88
+ threads: threads,
89
+ dead_letter: dead_letter_queue,
90
+ max_retry_count: max_retry_count
87
91
  }
88
92
  end
93
+ # rubocop:enable Metrics/ParameterLists
89
94
  end
90
95
  end
91
96
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: osbourne
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.2
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Steve Allen
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2018-10-10 00:00:00.000000000 Z
11
+ date: 2018-10-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: aws-sdk-core