osbourne 0.2.2 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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