circuitry 1.4.1 → 2.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 +4 -4
- data/.rubocop.yml +16 -0
- data/CHANGELOG.md +7 -0
- data/README.md +88 -62
- data/bin/console +4 -4
- data/circuitry.gemspec +12 -11
- data/exe/circuitry +7 -0
- data/lib/circuitry.rb +5 -3
- data/lib/circuitry/cli.rb +70 -0
- data/lib/circuitry/concerns/async.rb +11 -6
- data/lib/circuitry/configuration.rb +16 -9
- data/lib/circuitry/provisioner.rb +59 -0
- data/lib/circuitry/publisher.rb +18 -16
- data/lib/circuitry/queue.rb +27 -0
- data/lib/circuitry/queue_creator.rb +50 -0
- data/lib/circuitry/railtie.rb +9 -0
- data/lib/circuitry/subscriber.rb +56 -37
- data/lib/circuitry/subscription_creator.rb +60 -0
- data/lib/circuitry/tasks.rb +11 -0
- data/lib/circuitry/topic_creator.rb +2 -2
- data/lib/circuitry/version.rb +1 -1
- metadata +72 -45
@@ -0,0 +1,70 @@
|
|
1
|
+
require 'circuitry/provisioner'
|
2
|
+
require 'thor'
|
3
|
+
|
4
|
+
module Circuitry
|
5
|
+
class CLI < Thor
|
6
|
+
class_option :verbose, aliases: :v, type: :boolean
|
7
|
+
|
8
|
+
desc 'provision <queue> -t <topic> [<topic> ...]', 'Provision a queue subscribed to one or more topics'
|
9
|
+
|
10
|
+
long_desc <<-END
|
11
|
+
Creates an SQS queue with appropriate SNS access policy along with one or more SNS topics
|
12
|
+
named <topic> that has an SQS subscription for each.
|
13
|
+
|
14
|
+
When the queue already exists, its policy will be added or updated.
|
15
|
+
|
16
|
+
When a topic already exists, it will be ignored.
|
17
|
+
|
18
|
+
When a topic subscription already exists, it will be ignored.
|
19
|
+
|
20
|
+
If no dead letter queue is specified, one will be created by default with the
|
21
|
+
name <queue>-failures
|
22
|
+
END
|
23
|
+
|
24
|
+
option :topics, aliases: :t, type: :array, required: :true
|
25
|
+
option :access_key, aliases: :a
|
26
|
+
option :secret_key, aliases: :s
|
27
|
+
option :dead_letter_queue, aliases: :d
|
28
|
+
option :region, aliases: :r
|
29
|
+
|
30
|
+
def provision(queue_name)
|
31
|
+
with_custom_config(queue_name) do |config|
|
32
|
+
logger = Logger.new(STDOUT)
|
33
|
+
logger.level = Logger::INFO if options['verbose']
|
34
|
+
Circuitry::Provisioner.new(config, logger: logger).run
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
def say(*args)
|
41
|
+
puts(*args) if options['verbose']
|
42
|
+
end
|
43
|
+
|
44
|
+
def with_custom_config(queue_name, &block)
|
45
|
+
original_values = {}
|
46
|
+
%i[access_key secret_key region subscriber_queue_name subscriber_dead_letter_queue_name publisher_topic_names].each do |sym|
|
47
|
+
original_values[sym] = Circuitry.config.send(sym)
|
48
|
+
end
|
49
|
+
|
50
|
+
assign_options_config(queue_name, original_values)
|
51
|
+
|
52
|
+
block.call(Circuitry.config)
|
53
|
+
ensure
|
54
|
+
restore_config(original_values)
|
55
|
+
end
|
56
|
+
|
57
|
+
def assign_options_config(queue_name, original_values)
|
58
|
+
Circuitry.config.access_key = options.fetch('access_key', original_values[:access_key])
|
59
|
+
Circuitry.config.secret_key = options.fetch('secret_key', original_values[:secret_key])
|
60
|
+
Circuitry.config.region = options.fetch('region', original_values[:region])
|
61
|
+
Circuitry.config.subscriber_queue_name = queue_name
|
62
|
+
Circuitry.config.subscriber_dead_letter_queue_name = options.fetch('dead_letter_queue', "#{queue_name}-failures")
|
63
|
+
Circuitry.config.publisher_topic_names = options['topics']
|
64
|
+
end
|
65
|
+
|
66
|
+
def restore_config(original_values)
|
67
|
+
original_values.keys.each { |key| Circuitry.config.send(:"#{key}=", original_values[key]) }
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
@@ -29,11 +29,11 @@ module Circuitry
|
|
29
29
|
|
30
30
|
def async=(value)
|
31
31
|
value = case value
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
32
|
+
when false, nil then false
|
33
|
+
when true then self.class.default_async_strategy
|
34
|
+
when *self.class.async_strategies then value
|
35
|
+
else raise ArgumentError, async_value_error(value)
|
36
|
+
end
|
37
37
|
|
38
38
|
if value == :fork && !platform_supports_forking?
|
39
39
|
raise NotSupportedError, 'Your platform does not support forking'
|
@@ -43,11 +43,16 @@ module Circuitry
|
|
43
43
|
end
|
44
44
|
|
45
45
|
def async?
|
46
|
-
|
46
|
+
![nil, false].include?(async)
|
47
47
|
end
|
48
48
|
|
49
49
|
private
|
50
50
|
|
51
|
+
def async_value_error(value)
|
52
|
+
options = [true, false].concat(self.class.async_strategies).inspect
|
53
|
+
"Invalid value `#{value.inspect}`, must be one of #{options}"
|
54
|
+
end
|
55
|
+
|
51
56
|
def platform_supports_forking?
|
52
57
|
Process.respond_to?(:fork)
|
53
58
|
end
|
@@ -5,14 +5,18 @@ module Circuitry
|
|
5
5
|
class Configuration
|
6
6
|
include Virtus::Model
|
7
7
|
|
8
|
+
attribute :subscriber_queue_name, String
|
9
|
+
attribute :subscriber_dead_letter_queue_name, String
|
10
|
+
attribute :publisher_topic_names, Array[String]
|
11
|
+
|
8
12
|
attribute :access_key, String
|
9
13
|
attribute :secret_key, String
|
10
14
|
attribute :region, String, default: 'us-east-1'
|
11
15
|
attribute :logger, Logger, default: Logger.new(STDERR)
|
12
16
|
attribute :error_handler
|
13
|
-
attribute :lock_strategy, Object, default: ->(
|
14
|
-
attribute :publish_async_strategy, Symbol, default: ->(
|
15
|
-
attribute :subscribe_async_strategy, Symbol, default: ->(
|
17
|
+
attribute :lock_strategy, Object, default: ->(_page, _attribute) { Circuitry::Locks::Memory.new }
|
18
|
+
attribute :publish_async_strategy, Symbol, default: ->(_page, _attribute) { :fork }
|
19
|
+
attribute :subscribe_async_strategy, Symbol, default: ->(_page, _attribute) { :fork }
|
16
20
|
attribute :on_thread_exit
|
17
21
|
attribute :on_fork_exit
|
18
22
|
|
@@ -26,20 +30,23 @@ module Circuitry
|
|
26
30
|
super
|
27
31
|
end
|
28
32
|
|
33
|
+
def subscriber_dead_letter_queue_name
|
34
|
+
super || "#{subscriber_queue_name}-failures"
|
35
|
+
end
|
36
|
+
|
29
37
|
def aws_options
|
30
38
|
{
|
31
|
-
|
32
|
-
|
33
|
-
|
39
|
+
access_key_id: access_key,
|
40
|
+
secret_access_key: secret_key,
|
41
|
+
region: region
|
34
42
|
}
|
35
43
|
end
|
36
44
|
|
37
45
|
private
|
38
46
|
|
39
47
|
def validate(value, permitted_values)
|
40
|
-
|
41
|
-
|
42
|
-
end
|
48
|
+
return if permitted_values.include?(value)
|
49
|
+
raise ArgumentError, "invalid value `#{value}`, must be one of #{permitted_values.inspect}"
|
43
50
|
end
|
44
51
|
end
|
45
52
|
end
|
@@ -0,0 +1,59 @@
|
|
1
|
+
require 'circuitry/queue_creator'
|
2
|
+
require 'circuitry/topic_creator'
|
3
|
+
require 'circuitry/subscription_creator'
|
4
|
+
|
5
|
+
module Circuitry
|
6
|
+
class Provisioner
|
7
|
+
attr_reader :log
|
8
|
+
attr_reader :config
|
9
|
+
|
10
|
+
def initialize(config, logger: Logger.new(STDOUT))
|
11
|
+
@config = config
|
12
|
+
@log = logger
|
13
|
+
end
|
14
|
+
|
15
|
+
def run
|
16
|
+
queue = create_queue
|
17
|
+
topics = create_topics if queue
|
18
|
+
subscribe_topics(queue, topics) if queue && topics
|
19
|
+
end
|
20
|
+
|
21
|
+
private
|
22
|
+
|
23
|
+
def create_queue
|
24
|
+
safe_aws('Create Queue') do
|
25
|
+
queue = Circuitry::QueueCreator.find_or_create(
|
26
|
+
config.subscriber_queue_name,
|
27
|
+
dead_letter_queue_name: config.subscriber_dead_letter_queue_name
|
28
|
+
)
|
29
|
+
log.info "Created queue #{queue.url}"
|
30
|
+
queue
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def create_topics
|
35
|
+
safe_aws('Create Topics') do
|
36
|
+
config.publisher_topic_names.map do |topic_name|
|
37
|
+
topic = Circuitry::TopicCreator.find_or_create(topic_name)
|
38
|
+
log.info "Created topic #{topic.name}"
|
39
|
+
topic
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def subscribe_topics(queue, topics)
|
45
|
+
safe_aws('Subscribe Topics') do
|
46
|
+
Circuitry::SubscriptionCreator.subscribe_all(queue, topics)
|
47
|
+
log.info "Subscribed all topics to #{queue.name}"
|
48
|
+
true
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def safe_aws(desc)
|
53
|
+
yield
|
54
|
+
rescue Aws::SQS::Errors::AccessDenied
|
55
|
+
log.fatal("#{desc}: Access denied. Check your configured credentials.")
|
56
|
+
nil
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
data/lib/circuitry/publisher.rb
CHANGED
@@ -12,8 +12,8 @@ module Circuitry
|
|
12
12
|
include Services::SNS
|
13
13
|
|
14
14
|
DEFAULT_OPTIONS = {
|
15
|
-
|
16
|
-
|
15
|
+
async: false,
|
16
|
+
timeout: 15
|
17
17
|
}.freeze
|
18
18
|
|
19
19
|
attr_reader :timeout
|
@@ -26,23 +26,14 @@ module Circuitry
|
|
26
26
|
end
|
27
27
|
|
28
28
|
def publish(topic_name, object)
|
29
|
-
raise ArgumentError
|
30
|
-
raise ArgumentError
|
31
|
-
raise PublishError
|
32
|
-
|
33
|
-
process = -> do
|
34
|
-
Timeout.timeout(timeout) do
|
35
|
-
logger.info("Publishing message to #{topic_name}")
|
36
|
-
|
37
|
-
topic = TopicCreator.find_or_create(topic_name)
|
38
|
-
sns.publish(topic_arn: topic.arn, message: object.to_json)
|
39
|
-
end
|
40
|
-
end
|
29
|
+
raise ArgumentError, 'topic_name cannot be nil' if topic_name.nil?
|
30
|
+
raise ArgumentError, 'object cannot be nil' if object.nil?
|
31
|
+
raise PublishError, 'AWS configuration is not set' unless can_publish?
|
41
32
|
|
42
33
|
if async?
|
43
|
-
process_asynchronously(&
|
34
|
+
process_asynchronously(& -> { publish_internal(topic_name, object) })
|
44
35
|
else
|
45
|
-
|
36
|
+
publish_internal(topic_name, object)
|
46
37
|
end
|
47
38
|
end
|
48
39
|
|
@@ -52,6 +43,17 @@ module Circuitry
|
|
52
43
|
|
53
44
|
protected
|
54
45
|
|
46
|
+
def publish_internal(topic_name, object)
|
47
|
+
# TODO: Don't use ruby timeout.
|
48
|
+
# http://www.mikeperham.com/2015/05/08/timeout-rubys-most-dangerous-api/
|
49
|
+
Timeout.timeout(timeout) do
|
50
|
+
logger.info("Publishing message to #{topic_name}")
|
51
|
+
|
52
|
+
topic = TopicCreator.find_or_create(topic_name)
|
53
|
+
sns.publish(topic_arn: topic.arn, message: object.to_json)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
55
57
|
attr_writer :timeout
|
56
58
|
|
57
59
|
private
|
@@ -0,0 +1,27 @@
|
|
1
|
+
require 'circuitry/services/sqs'
|
2
|
+
|
3
|
+
module Circuitry
|
4
|
+
class Queue
|
5
|
+
include Services::SQS
|
6
|
+
|
7
|
+
attr_reader :url
|
8
|
+
|
9
|
+
def initialize(url)
|
10
|
+
@url = url
|
11
|
+
end
|
12
|
+
|
13
|
+
def name
|
14
|
+
url.split('/').last
|
15
|
+
end
|
16
|
+
|
17
|
+
def arn
|
18
|
+
@arn ||= attribute('QueueArn')
|
19
|
+
end
|
20
|
+
|
21
|
+
private
|
22
|
+
|
23
|
+
def attribute(name)
|
24
|
+
sqs.get_queue_attributes(queue_url: url, attribute_names: [name]).attributes[name]
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
require 'circuitry/services/sqs'
|
2
|
+
require 'circuitry/queue'
|
3
|
+
|
4
|
+
module Circuitry
|
5
|
+
class QueueCreator
|
6
|
+
include Services::SQS
|
7
|
+
|
8
|
+
attr_reader :queue_name
|
9
|
+
|
10
|
+
def self.find_or_create(queue_name, dead_letter_queue_name: nil, max_receive_count: 8 )
|
11
|
+
creator = new(queue_name)
|
12
|
+
result = creator.create_queue
|
13
|
+
creator.create_dead_letter_queue(dead_letter_queue_name, max_receive_count) if dead_letter_queue_name
|
14
|
+
result
|
15
|
+
end
|
16
|
+
|
17
|
+
def initialize(queue_name)
|
18
|
+
@queue_name = queue_name
|
19
|
+
end
|
20
|
+
|
21
|
+
def create_queue
|
22
|
+
@_queue ||= Queue.new(create_primary_queue_internal)
|
23
|
+
end
|
24
|
+
|
25
|
+
def create_dead_letter_queue(name, max_receive_count)
|
26
|
+
@_dl_queue ||= Queue.new(create_dl_queue_internal(name, max_receive_count))
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
def create_dl_queue_internal(name, max_receive_count)
|
32
|
+
dl_url = sqs.create_queue(queue_name: name).queue_url
|
33
|
+
dl_arn = sqs.get_queue_attributes(queue_url: dl_url, attribute_names: ['QueueArn']).attributes['QueueArn']
|
34
|
+
|
35
|
+
sqs.set_queue_attributes(queue_url: create_queue.url, attributes: build_redrive_policy(dl_arn, max_receive_count))
|
36
|
+
dl_url
|
37
|
+
end
|
38
|
+
|
39
|
+
def build_redrive_policy(deadletter_arn, max_receive_count)
|
40
|
+
{
|
41
|
+
'RedrivePolicy' => %({"maxReceiveCount":"#{max_receive_count}", "deadLetterTargetArn":"#{deadletter_arn}"})
|
42
|
+
}
|
43
|
+
end
|
44
|
+
|
45
|
+
def create_primary_queue_internal
|
46
|
+
attributes = { 'VisibilityTimeout' => (30 * 60).to_s }
|
47
|
+
sqs.create_queue(queue_name: queue_name, attributes: attributes).queue_url
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
data/lib/circuitry/subscriber.rb
CHANGED
@@ -14,36 +14,33 @@ module Circuitry
|
|
14
14
|
attr_reader :queue, :timeout, :wait_time, :batch_size, :lock
|
15
15
|
|
16
16
|
DEFAULT_OPTIONS = {
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
17
|
+
lock: true,
|
18
|
+
async: false,
|
19
|
+
timeout: 15,
|
20
|
+
wait_time: 10,
|
21
|
+
batch_size: 10
|
22
22
|
}.freeze
|
23
23
|
|
24
24
|
CONNECTION_ERRORS = [
|
25
|
-
|
25
|
+
Aws::SQS::Errors::ServiceError
|
26
26
|
].freeze
|
27
27
|
|
28
|
-
def initialize(
|
29
|
-
raise ArgumentError.new('queue cannot be nil') if queue.nil?
|
30
|
-
|
28
|
+
def initialize(options = {})
|
31
29
|
options = DEFAULT_OPTIONS.merge(options)
|
32
30
|
|
33
31
|
self.subscribed = false
|
34
|
-
|
35
|
-
self.
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
self.batch_size = options[:batch_size]
|
32
|
+
|
33
|
+
self.queue = QueueCreator.find_or_create(Circuitry.config.subscriber_queue_name).url
|
34
|
+
%i[lock async timeout wait_time batch_size].each do |sym|
|
35
|
+
send(:"#{sym}=", options[sym])
|
36
|
+
end
|
40
37
|
|
41
38
|
trap_signals
|
42
39
|
end
|
43
40
|
|
44
41
|
def subscribe(&block)
|
45
|
-
raise ArgumentError
|
46
|
-
raise SubscribeError
|
42
|
+
raise ArgumentError, 'block required' if block.nil?
|
43
|
+
raise SubscribeError, 'AWS configuration is not set' unless can_subscribe?
|
47
44
|
|
48
45
|
logger.info("Subscribing to queue: #{queue}")
|
49
46
|
|
@@ -54,7 +51,7 @@ module Circuitry
|
|
54
51
|
logger.info("Unsubscribed from queue: #{queue}")
|
55
52
|
rescue *CONNECTION_ERRORS => e
|
56
53
|
logger.error("Connection error to queue: #{queue}: #{e}")
|
57
|
-
raise SubscribeError.
|
54
|
+
raise SubscribeError, e.message
|
58
55
|
end
|
59
56
|
|
60
57
|
def subscribed?
|
@@ -76,17 +73,22 @@ module Circuitry
|
|
76
73
|
|
77
74
|
def lock=(value)
|
78
75
|
value = case value
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
76
|
+
when true then Circuitry.config.lock_strategy
|
77
|
+
when false then Circuitry::Locks::NOOP.new
|
78
|
+
when Circuitry::Locks::Base then value
|
79
|
+
else raise ArgumentError, lock_value_error(value)
|
80
|
+
end
|
84
81
|
|
85
82
|
@lock = value
|
86
83
|
end
|
87
84
|
|
88
85
|
private
|
89
86
|
|
87
|
+
def lock_value_error(value)
|
88
|
+
opts = Circuitry::Locks::Base
|
89
|
+
"Invalid value `#{value}`, must be one of `true`, `false`, or instance of `#{opts}`"
|
90
|
+
end
|
91
|
+
|
90
92
|
def trap_signals
|
91
93
|
trap('SIGINT') do
|
92
94
|
if subscribed?
|
@@ -109,19 +111,21 @@ module Circuitry
|
|
109
111
|
end
|
110
112
|
|
111
113
|
def process_messages(messages, &block)
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
if async?
|
118
|
-
process_asynchronously(&process)
|
119
|
-
else
|
120
|
-
process.call
|
121
|
-
end
|
114
|
+
if async?
|
115
|
+
process_messages_asynchronously(messages, &block)
|
116
|
+
else
|
117
|
+
process_messages_synchronously(messages, &block)
|
122
118
|
end
|
123
119
|
end
|
124
120
|
|
121
|
+
def process_messages_asynchronously(messages, &block)
|
122
|
+
messages.each { |message| process_asynchronously(& -> { process_message(message, &block) }) }
|
123
|
+
end
|
124
|
+
|
125
|
+
def process_messages_synchronously(messages, &block)
|
126
|
+
messages.each { |message| process_message(message, &block) }
|
127
|
+
end
|
128
|
+
|
125
129
|
def process_message(message, &block)
|
126
130
|
message = Message.new(message)
|
127
131
|
|
@@ -134,20 +138,35 @@ module Circuitry
|
|
134
138
|
end
|
135
139
|
|
136
140
|
def handle_message(message, &block)
|
137
|
-
|
141
|
+
handled = try_with_lock(message.id) do
|
138
142
|
begin
|
143
|
+
# TODO: Don't use ruby timeout.
|
144
|
+
# http://www.mikeperham.com/2015/05/08/timeout-rubys-most-dangerous-api/
|
139
145
|
Timeout.timeout(timeout) do
|
140
146
|
block.call(message.body, message.topic.name)
|
141
147
|
end
|
142
148
|
rescue => e
|
143
|
-
lock.unlock(message.id)
|
144
149
|
logger.error("Error handling message #{message.id}: #{e}")
|
145
150
|
raise e
|
146
151
|
end
|
152
|
+
end
|
153
|
+
|
154
|
+
logger.info("Ignoring duplicate message #{message.id}") unless handled
|
155
|
+
end
|
156
|
+
|
157
|
+
def try_with_lock(handle)
|
158
|
+
if lock.soft_lock(handle)
|
159
|
+
begin
|
160
|
+
yield
|
161
|
+
rescue => e
|
162
|
+
lock.unlock(handle)
|
163
|
+
raise e
|
164
|
+
end
|
147
165
|
|
148
|
-
lock.hard_lock(
|
166
|
+
lock.hard_lock(handle)
|
167
|
+
true
|
149
168
|
else
|
150
|
-
|
169
|
+
false
|
151
170
|
end
|
152
171
|
end
|
153
172
|
|