sqs_simplify 0.2.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.
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+
5
+ module SqsSimplify
6
+ class Command
7
+ attr_reader :env, :queues, :with_priority, :parallel_type, :worker_size
8
+
9
+ def initialize(args = [])
10
+ args = [] unless args.is_a? Array
11
+
12
+ OptionParser.new do |opt|
13
+ opt.on('-h', '--help', 'Show help') do
14
+ puts opt
15
+ exit 1
16
+ end
17
+
18
+ opt.on('-n', '--number_of_workers=workers', 'Number of unique workers to spawn') do |worker_count|
19
+ @worker_size = build_work_count(worker_count)
20
+ end
21
+
22
+ opt.on('-e', '--environment=environment', 'Environment') { |env| @env = env }
23
+ opt.on('--queues=queues', 'queues that will be consumed') { |queues| @queues = queues.split(',') }
24
+ opt.on('--priority', 'with priority in the queues') { |priority| @with_priority = priority }
25
+ opt.on('-f', '--fork', 'parallel in processes') { @parallel_type = :in_processes }
26
+ opt.on('-t', '--thread', 'parallel in threads') { @parallel_type = :in_threads }
27
+ end.parse! args
28
+ end
29
+
30
+ def run(&block)
31
+ @running_process = true
32
+ block&.call(self)
33
+ run!
34
+ end
35
+
36
+ private
37
+
38
+ def build_work_count(number)
39
+ worker_size = number.to_i
40
+ worker_size.positive? && worker_size <= 11 ? worker_size : 1
41
+ end
42
+
43
+ def running_process?
44
+ @running_process
45
+ end
46
+
47
+ def run!
48
+ time = 0
49
+ trap('SIGINT') { stop! }
50
+
51
+ while running_process?
52
+ if time.zero?
53
+ amount_process = execute
54
+ time = calculate_sleep(amount_process)
55
+ else
56
+ sleep 1
57
+ time -= 1
58
+ end
59
+ end
60
+ end
61
+
62
+ def stop!
63
+ puts ''
64
+ puts 'this process will stop after consuming all messages!'
65
+ @running_process = false
66
+ end
67
+
68
+ def execute
69
+ worker = SqsSimplify::Worker.new(
70
+ queues: queues,
71
+ priority: with_priority,
72
+ worker_size: worker_size,
73
+ parallel_type: parallel_type
74
+ ) do
75
+ continues_the_process?
76
+ end
77
+
78
+ worker.perform
79
+ end
80
+
81
+ def calculate_sleep(amount)
82
+ amount.to_i.zero? ? 10 : 0
83
+ end
84
+
85
+ def continues_the_process?
86
+ running_process?
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SqsSimplify
4
+ class Consumer < SqsSimplify::Base
5
+ include SqsSimplify::ExecutionHook
6
+
7
+ private_class_method :new
8
+ attr_accessor :delete_sqs_message
9
+ attr_reader :visibility_timeout, :errors
10
+
11
+ def initialize(sqs_message, visibility_timeout)
12
+ @sqs_message = sqs_message
13
+ @message = load_message(sqs_message)
14
+ @visibility_timeout = visibility_timeout
15
+ self.delete_sqs_message = visibility_timeout.positive?
16
+ end
17
+
18
+ def perform
19
+ raise 'Must implement this method'
20
+ end
21
+
22
+ def delete_sqs_message?
23
+ delete_sqs_message
24
+ end
25
+
26
+ protected
27
+
28
+ attr_reader :message, :sqs_message
29
+
30
+ class << self
31
+ def consume_messages(amount = nil, worker_size: nil, parallel_type: nil)
32
+ amount ||= maximum_message_quantity
33
+ @worker_size = worker_size.to_i
34
+ @parallel_type = parallel_type
35
+
36
+ visibility_timeout # load
37
+ sqs_messages = fetch_messages(amount)
38
+ consume_sqs_messages(sqs_messages, Time.now)
39
+ rescue Exception => e
40
+ call_hook :resolver_exception, e, sqs_messages
41
+ ensure
42
+ return sqs_messages&.count || 0
43
+ end
44
+
45
+ def message_not_deleted(&block)
46
+ hooks[:message_not_deleted] = block
47
+ end
48
+
49
+ def around(&block)
50
+ hooks[:around] = block
51
+ end
52
+
53
+ protected
54
+
55
+ def maximum_message_quantity
56
+ number = settings[:maximum_message_quantity].to_i
57
+ number.positive? && number <= 10 ? number : 10
58
+ rescue StandardError
59
+ 10
60
+ end
61
+
62
+ private
63
+
64
+ def fetch_messages(amount = 10)
65
+ client.receive_message(
66
+ queue_url: queue_url,
67
+ message_attribute_names: ['All'], # Receive all custom attributes.
68
+ max_number_of_messages: amount, # Receive at most one message.
69
+ wait_time_seconds: 0 # Do not wait to check for the message.
70
+ ).messages
71
+ end
72
+
73
+ def consume_sqs_messages(sqs_messages, start_time)
74
+ call_hook(:before_all, sqs_messages)
75
+ benchmark { choose_process(sqs_messages, start_time) }
76
+ call_hook(:after_all, sqs_messages)
77
+ end
78
+
79
+ def choose_process(messages, start_time)
80
+ parallel_type, amount_processes = build_parallel_parameter(messages.size)
81
+
82
+ logger.info "Started loop with: { queue_name: #{queue_name}, #{parallel_type}: #{amount_processes}, messages: #{messages.count} }"
83
+ if amount_processes == 1
84
+ messages.each { |sqs_message| consume_sqs_message(sqs_message, start_time) }
85
+ else
86
+ Parallel.each(messages, parallel_type => amount_processes) do |sqs_message|
87
+ consume_sqs_message(sqs_message, start_time)
88
+ end
89
+ end
90
+ end
91
+
92
+ def build_parallel_parameter(messages_size)
93
+ process_in_parallel = messages_size > 1 &&
94
+ @worker_size > 1 &&
95
+ %w[in_threads in_processes].include?(@parallel_type.to_s)
96
+
97
+ process_in_parallel ? [@parallel_type, @worker_size] : [:without_parallel, 1]
98
+ end
99
+
100
+ def consume_sqs_message(sqs_message, start_time)
101
+ time_left = build_time_left(start_time)
102
+ consumer = new(sqs_message, time_left)
103
+ call_hook(:before_each, consumer)
104
+
105
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
106
+ Timeout.timeout(time_left, Errors::ExecutionExpired) do
107
+ around.is_a?(Proc) ? around.call(consumer) : consumer.perform
108
+ end
109
+ raise Errors::ExecutionExpired if Process.clock_gettime(Process::CLOCK_MONOTONIC) - started >= time_left
110
+ rescue Exception => e
111
+ if consumer
112
+ consumer.delete_sqs_message = false
113
+ consumer.instance_variable_set :@errors, ["Exception: #{e.message}"] + e.backtrace
114
+ end
115
+ call_hook :resolver_exception, e, consumer
116
+ ensure
117
+ call_hook(:after_each, consumer)
118
+ delete_message(consumer)
119
+ end
120
+
121
+ def build_time_left(start_time)
122
+ time_left = visibility_timeout - (Time.now - start_time).to_f
123
+ time_left = -0.1 if time_left.zero?
124
+ [time_left, 0.1].max
125
+ end
126
+
127
+ def delete_message(consumer)
128
+ if consumer&.delete_sqs_message
129
+ delete_sqs_message(consumer.send(:sqs_message))
130
+ else
131
+ call_hook :message_not_deleted, consumer
132
+ end
133
+ end
134
+
135
+ def delete_sqs_message(message)
136
+ client.delete_message(
137
+ queue_url: queue_url,
138
+ receipt_handle: message.receipt_handle
139
+ )
140
+ true
141
+ end
142
+
143
+ def benchmark(&block)
144
+ start_time = Time.now
145
+ block.call
146
+ logger.info "Finished in #{(Time.now - start_time).to_f} seconds"
147
+ end
148
+
149
+ def logger
150
+ SqsSimplify.logger
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SqsSimplify
4
+ class DeadQueue < SqsSimplify::Base
5
+ VISIBILITY_TIMEOUT = 60 * 60
6
+ MESSAGE_RETENTION_PERIOD = 14 * 24 * 60 * 60
7
+ DELAY_SECONDS = 0
8
+
9
+ class << self
10
+ undef_method :max_receive_count
11
+
12
+ def main_queue
13
+ @main_queue ||= const_get(name.gsub('::DeadQueue', ''))
14
+ end
15
+
16
+ def create_queue
17
+ dead_queue = find_queue_by_name(queue_full_name)
18
+ dead_queue ||= super
19
+
20
+ # Use a redrive policy to specify the dead letter queue and its behavior.
21
+ redrive_policy = {
22
+ 'maxReceiveCount' => main_queue.max_receive_count.to_s, # After the queue receives the same message 1 times, send that message to the dead letter queue.
23
+ 'deadLetterTargetArn' => queue_arn
24
+ }.to_json
25
+
26
+ main_queue.update_attributes(RedrivePolicy: redrive_policy)
27
+ dead_queue
28
+ end
29
+
30
+ def send_message_to_main_queue(amount)
31
+ raise "Invalid argument #{amount}" unless amount.is_a?(Numeric) ||
32
+ amount.positive?
33
+
34
+ dead_queue_to_queue_loop(amount)
35
+ end
36
+
37
+ private
38
+
39
+ def dead_queue_to_queue_loop(amount)
40
+ count = 0
41
+
42
+ loop do
43
+ messages = dead_queue_messages([amount, 10].min)
44
+ count += messages.count
45
+
46
+ messages.each do |message|
47
+ client.send_message(queue_url: main_queue.queue_url, message_body: message[:body])
48
+ client.delete_message(queue_url: queue_url, receipt_handle: message.receipt_handle)
49
+ end
50
+
51
+ return count if messages.count.zero? || count >= amount
52
+ end
53
+ end
54
+
55
+ def dead_queue_messages(amount)
56
+ client.receive_message(
57
+ queue_url: queue_url,
58
+ message_attribute_names: ['All'],
59
+ max_number_of_messages: amount,
60
+ wait_time_seconds: 0
61
+ ).messages
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SqsSimplify
4
+ module Errors
5
+ class ExecutionExpired < ::Timeout::Error
6
+ def initialize(message = 'execution expired')
7
+ super
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SqsSimplify
4
+ module Errors
5
+ class NonExistentQueue < StandardError
6
+ def initialize(queue_full_name)
7
+ super "The queue `#{queue_full_name}' does not exist."
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SqsSimplify
4
+ module Errors
5
+ class ReservedMethodName < StandardError
6
+ def initialize(method)
7
+ super "reserved method name `#{method}'."
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SqsSimplify
4
+ module ExecutionHook
5
+ module ClassMethods
6
+ def after(type = :each, &block)
7
+ type = "after_#{type}".to_sym
8
+ hooks[type] = block
9
+ end
10
+
11
+ def before(type = :each, &block)
12
+ type = "before_#{type}".to_sym
13
+ hooks[type] = block
14
+ end
15
+
16
+ def call_hook(type, arg = nil, args = {})
17
+ block = hooks[type.to_sym]
18
+ SqsSimplify.call_hook(type, arg, args) unless self == SqsSimplify
19
+ block&.call(arg, args)
20
+ end
21
+
22
+ def hooks
23
+ @hooks ||= {}
24
+ end
25
+
26
+ def resolver_exception(&block)
27
+ hooks[:resolver_exception] = block
28
+ end
29
+
30
+ def add_hook(hook_name, *hook_names)
31
+ hook_names.unshift(hook_name)
32
+ hook_names.each do |name|
33
+ define_singleton_method(name) do |&block|
34
+ hooks[name.to_sym] = block
35
+ end
36
+ end
37
+ end
38
+ end
39
+
40
+ def self.included(base)
41
+ base.extend ClassMethods
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SqsSimplify
4
+ class FakerClient
5
+ def send_message(sqs_message)
6
+ id = next_id
7
+ url = sqs_message[:queue_url]
8
+ body = sqs_message[:message_body]
9
+ message = OpenStruct.new(receipt_handle: id, body: body, queue_url: url)
10
+
11
+ if pool[url]
12
+ pool[url] << message
13
+ else
14
+ pool[url] = [message]
15
+ end
16
+ OpenStruct.new(message_id: id)
17
+ end
18
+
19
+ def receive_message(queue_url:, max_number_of_messages:, **_args)
20
+ amount =
21
+ if max_number_of_messages.nil? || !max_number_of_messages.is_a?(Integer)
22
+ 10
23
+ elsif max_number_of_messages.positive? && max_number_of_messages < 11
24
+ max_number_of_messages
25
+ end
26
+
27
+ messages = (pool[queue_url] || [])[0, amount]
28
+ OpenStruct.new(messages: messages)
29
+ end
30
+
31
+ def delete_message(queue_url:, receipt_handle:)
32
+ messages = pool[queue_url] || []
33
+ messages.delete_if do |message|
34
+ message.receipt_handle == receipt_handle && message.queue_url == queue_url
35
+ end
36
+
37
+ true
38
+ end
39
+
40
+ def get_queue_attributes(queue_url:, **_args)
41
+ messages = pool[queue_url] || []
42
+ OpenStruct.new(attributes: {
43
+ 'ApproximateNumberOfMessages' => messages.count,
44
+ 'VisibilityTimeout' => 60
45
+ })
46
+ end
47
+
48
+ def get_queue_url(queue_name:)
49
+ OpenStruct.new queue_url: "http//sqs_faker/#{queue_name}"
50
+ end
51
+
52
+ private
53
+
54
+ def pool
55
+ self.class.pool
56
+ end
57
+
58
+ def next_id
59
+ self.class.next_id
60
+ end
61
+
62
+ class << self
63
+ def next_id
64
+ @next_id ||= 0
65
+ @next_id += 1
66
+ @next_id.to_s(16)
67
+ end
68
+
69
+ def pool
70
+ @pool ||= {}
71
+ end
72
+
73
+ def purger!
74
+ @pool = {}
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zlib'
4
+
5
+ module SqsSimplify
6
+ class Job < SqsSimplify::Base
7
+ set :scheduler, true
8
+ private_class_method :new
9
+
10
+ def perform(*_rest, **_keyrest)
11
+ raise 'NotImplemented'
12
+ end
13
+
14
+ def new_job(**options)
15
+ self.class.new_job(**options)
16
+ end
17
+
18
+ def dump(parameters)
19
+ self.class.send :_dump, parameters
20
+ end
21
+
22
+ class << self
23
+ def new_job(**options)
24
+ self::ProxyJob.new(new, **options)
25
+ end
26
+
27
+ def perform(*rest, **keyrest)
28
+ new_job.perform(*rest, **keyrest)
29
+ end
30
+
31
+ def perform_later(*rest, **keyrest)
32
+ new_job.perform_later(*rest, **keyrest)
33
+ end
34
+
35
+ def consume_messages(amount = nil, **options)
36
+ consumer.consume_messages amount, **options
37
+ end
38
+
39
+ def scheduler
40
+ @scheduler ||= _build_scheduler
41
+ end
42
+
43
+ def consumer
44
+ @consumer ||= _build_consumer
45
+ end
46
+
47
+ def call_validator_parameters!(array, hash)
48
+ _validator_parameters.call(*array, **hash)
49
+ end
50
+
51
+ private
52
+
53
+ def _validator_parameters
54
+ @_validator_parameters ||= _build_validator_parameters
55
+ end
56
+
57
+ def _build_validator_parameters
58
+ parameters = new.method(:perform).parameters
59
+ params = parameters.map do |opt, name|
60
+ _parameters_to_args opt, name
61
+ end
62
+
63
+ class_eval "->(#{params.join(', ')}) {}", __FILE__, __LINE__
64
+ end
65
+
66
+ def _parameters_to_args(opt, name)
67
+ case opt
68
+ when :req
69
+ name.to_s
70
+ when :opt
71
+ "#{name}=nil"
72
+ when :rest
73
+ "*#{name}"
74
+ when :keyreq
75
+ "#{name}:"
76
+ when :key
77
+ "#{name}:nil"
78
+ when :keyrest
79
+ "**#{name}"
80
+ when :block
81
+ "&#{name}"
82
+ end
83
+ end
84
+
85
+ def inherited(sub)
86
+ super
87
+
88
+ if sub.superclass == SqsSimplify::Job
89
+ sub.set :scheduler, true
90
+ else
91
+ sub.instance_variable_set :@settings, settings.clone
92
+ end
93
+ end
94
+
95
+ def _execute(args)
96
+ class_name = args['class']
97
+ array_params, hash_params = _read_parameters args['parameters']
98
+
99
+ source = const_get(class_name)
100
+ instance = source.send :new
101
+ instance.send(:perform, *array_params, **hash_params)
102
+ end
103
+
104
+ def _read_parameters(parameters)
105
+ args = _load(parameters)
106
+ [args[:array], args[:hash]]
107
+ end
108
+
109
+ def _build_scheduler
110
+ klass = _build_sub_class 'Scheduler'
111
+ klass.private_class_method :send_message
112
+ klass
113
+ end
114
+
115
+ def _build_consumer
116
+ klass = _build_sub_class 'Consumer'
117
+ klass.define_method(:perform) { SqsSimplify::Job.send :_execute, message }
118
+ klass
119
+ end
120
+
121
+ def _build_sub_class(module_name)
122
+ class_eval <<~M, __FILE__, __LINE__ + 1
123
+ class #{self}::#{module_name}Job < SqsSimplify::#{module_name}
124
+ def self.settings
125
+ #{self}.settings
126
+ end
127
+
128
+ def self.client
129
+ #{self}.client
130
+ end
131
+
132
+ def self.queue_name
133
+ #{self}.queue_name
134
+ end
135
+ end
136
+ M
137
+
138
+ const_get "#{self}::#{module_name}Job"
139
+ end
140
+
141
+ def _dump(value)
142
+ Base64.encode64(Zlib::Deflate.deflate(Marshal.dump(value)))
143
+ end
144
+
145
+ def _load(value)
146
+ Marshal.load(Zlib::Inflate.inflate(Base64.decode64(value)))
147
+ end
148
+ end
149
+
150
+ class ProxyJob
151
+ def initialize(job, **options)
152
+ @job = job
153
+ @after = options[:after]
154
+ @queue_url = options[:queue_url]
155
+ end
156
+
157
+ def perform(*array, **hash)
158
+ @job.perform(*array, **hash)
159
+ :executed
160
+ end
161
+
162
+ def perform_later(*array, **hash)
163
+ return perform(*array, **hash) unless scheduler?
164
+
165
+ @job.class.call_validator_parameters! array, hash
166
+ message = {
167
+ 'class' => @job.class.name,
168
+ 'parameters' => @job.dump(array: array, hash: hash)
169
+ }
170
+
171
+ send_message message
172
+ end
173
+
174
+ private
175
+
176
+ def send_message(message)
177
+ @job.class.scheduler.send(:send_message, message: message, after: @after, queue_url: @queue_url)
178
+ end
179
+
180
+ def respond_to_missing?(symbol, value = nil)
181
+ @job.respond_to? symbol, value
182
+ end
183
+
184
+ def method_missing(symbol, *rest, **keyrest)
185
+ @job.send symbol, *rest, **keyrest
186
+ end
187
+
188
+ def scheduler?
189
+ SqsSimplify::Job.settings[:scheduler] && @job.class.settings[:scheduler]
190
+ end
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SqsSimplify
4
+ class Message
5
+ attr_accessor :body, :delay_seconds, :queue_url, :group_id
6
+
7
+ def initialize(body:, queue_url:, delay_seconds: 0, group_id: nil)
8
+ self.body = body
9
+ self.delay_seconds = delay_seconds
10
+ self.queue_url = queue_url
11
+ self.group_id = group_id
12
+ end
13
+
14
+ def to_send
15
+ hash = {
16
+ queue_url: queue_url,
17
+ message_body: body,
18
+ delay_seconds: delay_seconds
19
+ }
20
+ hash[:message_group_id] = group_id.to_s if group_id
21
+ hash
22
+ end
23
+ end
24
+ end