sidekiq_sqs_processor 0.1.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,54 @@
1
+
2
+ require 'rails/generators/named_base'
3
+
4
+ module SidekiqSqsProcessor
5
+ module Generators
6
+ class WorkerGenerator < Rails::Generators::NamedBase
7
+ source_root File.expand_path('templates', __dir__)
8
+
9
+ desc "Creates a SidekiqSqsProcessor worker for processing SQS messages."
10
+
11
+ class_option :queue, type: :string, default: nil,
12
+ desc: "The Sidekiq queue for this worker (defaults to sqs_[worker_name])"
13
+
14
+ class_option :retry, type: :numeric, default: nil,
15
+ desc: "Number of retries for this worker (defaults to configuration value)"
16
+
17
+ class_option :test, type: :boolean, default: true,
18
+ desc: "Generate test file for this worker"
19
+
20
+ def create_worker_file
21
+ template "worker.rb.tt", "app/workers/#{file_name}_worker.rb"
22
+ end
23
+
24
+ def create_test_file
25
+ return unless options[:test]
26
+
27
+ if defined?(RSpec) || File.exist?(File.join(destination_root, 'spec'))
28
+ template "worker_spec.rb.tt", "spec/workers/#{file_name}_worker_spec.rb"
29
+ elsif File.exist?(File.join(destination_root, 'test'))
30
+ template "worker_test.rb.tt", "test/workers/#{file_name}_worker_test.rb"
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def queue_name
37
+ options[:queue] || "sqs_#{file_name.underscore}"
38
+ end
39
+
40
+ def retry_option
41
+ if options[:retry]
42
+ ", retry: #{options[:retry]}"
43
+ else
44
+ ""
45
+ end
46
+ end
47
+
48
+ def worker_class_name
49
+ "#{class_name}Worker"
50
+ end
51
+ end
52
+ end
53
+ end
54
+
@@ -0,0 +1,82 @@
1
+ require "sidekiq"
2
+
3
+ module SidekiqSqsProcessor
4
+ class BaseWorker
5
+ include Sidekiq::Worker
6
+
7
+ class << self
8
+ def process_message_automatically
9
+ return @process_message_automatically if defined?(@process_message_automatically)
10
+ @process_message_automatically = true
11
+ end
12
+
13
+ def process_message_automatically=(value)
14
+ @process_message_automatically = value
15
+ end
16
+
17
+ def inherited(subclass)
18
+ super
19
+ # Copy the process_message_automatically value to subclasses
20
+ subclass.process_message_automatically = process_message_automatically
21
+ end
22
+ end
23
+
24
+ # Configure Sidekiq options with defaults
25
+ sidekiq_options(
26
+ retry: 2, # Default retry count
27
+ queue: 'default' # Default queue name
28
+ )
29
+
30
+ def perform(message_data)
31
+ process_message_data(message_data) if self.class.process_message_automatically
32
+ end
33
+
34
+ # Override this method in your worker
35
+ def process_message(body)
36
+ raise NotImplementedError, "You must implement process_message in your worker"
37
+ end
38
+
39
+ private
40
+
41
+ def process_message_data(message_data)
42
+ begin
43
+ # Parse and process the message
44
+ body = JSON.parse(message_data["body"])
45
+ process_message(body)
46
+
47
+ # Delete the message from SQS after successful processing
48
+ delete_sqs_message(message_data)
49
+ rescue JSON::ParserError => e
50
+ handle_error(e, message_data, "Failed to parse message body")
51
+ rescue Aws::SQS::Errors::ServiceError => e
52
+ handle_error(e, message_data, "SQS service error")
53
+ rescue StandardError => e
54
+ handle_error(e, message_data, "Error processing message")
55
+ end
56
+ end
57
+
58
+ def delete_sqs_message(message_data)
59
+ return unless message_data["queue_url"] && message_data["receipt_handle"]
60
+
61
+ SidekiqSqsProcessor.sqs_client.delete_message(
62
+ queue_url: message_data["queue_url"],
63
+ receipt_handle: message_data["receipt_handle"]
64
+ )
65
+ rescue Aws::SQS::Errors::ServiceError => e
66
+ handle_error(e, message_data, "Failed to delete message from SQS")
67
+ end
68
+
69
+ def handle_error(error, message_data, description)
70
+ SidekiqSqsProcessor.handle_error(error, {
71
+ worker: self.class.name,
72
+ message: message_data,
73
+ description: description
74
+ })
75
+ raise # Re-raise to trigger Sidekiq retry
76
+ end
77
+
78
+ def logger
79
+ SidekiqSqsProcessor.configuration.logger || Sidekiq.logger
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,182 @@
1
+ module SidekiqSqsProcessor
2
+ class Configuration
3
+ # AWS credentials
4
+ attr_accessor :aws_access_key_id, :aws_secret_access_key, :aws_credentials
5
+ attr_reader :aws_region, :queue_urls, :max_number_of_messages,
6
+ :visibility_timeout, :wait_time_seconds, :poller_thread_count,
7
+ :error_handler, :polling_enabled, :polling_type, :poll_on_startup,
8
+ :polling_frequency, :log_level
9
+
10
+ # Worker configuration
11
+ attr_accessor :worker_retry_count, :worker_queue_name, :logger
12
+
13
+ def initialize
14
+ @aws_region = "us-east-1"
15
+ @queue_urls = []
16
+ @max_number_of_messages = 10
17
+ @visibility_timeout = 30
18
+ @wait_time_seconds = 20
19
+ @poller_thread_count = 1
20
+ @worker_retry_count = 25
21
+ @worker_queue_name = "default"
22
+ @error_handler = default_error_handler
23
+ @polling_enabled = true
24
+ @polling_type = :continuous
25
+ @poll_on_startup = true
26
+ @polling_frequency = 60 # seconds
27
+ @log_level = Logger::INFO
28
+ end
29
+
30
+ def aws_region=(region)
31
+ @aws_region = region&.strip
32
+ end
33
+
34
+ def queue_urls=(urls)
35
+ @queue_urls = Array(urls).map(&:strip)
36
+ end
37
+
38
+ def add_queue_url(url)
39
+ @queue_urls << url.strip
40
+ @queue_urls.uniq!
41
+ end
42
+
43
+ def max_number_of_messages=(value)
44
+ @max_number_of_messages = value.to_i
45
+ end
46
+
47
+ def visibility_timeout=(value)
48
+ @visibility_timeout = value.to_i
49
+ end
50
+
51
+ def wait_time_seconds=(value)
52
+ @wait_time_seconds = value.to_i
53
+ end
54
+
55
+ def poller_thread_count=(value)
56
+ @poller_thread_count = value.to_i
57
+ end
58
+
59
+ def error_handler=(handler)
60
+ if !handler.respond_to?(:call) && !handler.nil?
61
+ raise ArgumentError, "Error handler must be callable (respond to #call)"
62
+ end
63
+ @error_handler = handler
64
+ end
65
+
66
+ def polling_enabled=(value)
67
+ @polling_enabled = !!value
68
+ end
69
+
70
+ def polling_type=(value)
71
+ value = value.to_sym if value.respond_to?(:to_sym)
72
+ unless [:continuous, :scheduled].include?(value)
73
+ raise ArgumentError, "polling_type must be :continuous or :scheduled"
74
+ end
75
+ @polling_type = value
76
+ end
77
+
78
+ def poll_on_startup=(value)
79
+ @poll_on_startup = !!value
80
+ end
81
+
82
+ def polling_frequency=(value)
83
+ @polling_frequency = [value.to_i, 1].max
84
+ end
85
+
86
+ def log_level=(level)
87
+ if level.is_a?(Integer) && (0..5).include?(level)
88
+ @log_level = level
89
+ elsif level.is_a?(Symbol) || level.is_a?(String)
90
+ level_map = {
91
+ debug: Logger::DEBUG,
92
+ info: Logger::INFO,
93
+ warn: Logger::WARN,
94
+ error: Logger::ERROR,
95
+ fatal: Logger::FATAL,
96
+ unknown: Logger::UNKNOWN
97
+ }
98
+ sym_level = level.to_sym.downcase
99
+ if level_map.key?(sym_level)
100
+ @log_level = level_map[sym_level]
101
+ else
102
+ raise ArgumentError, "Invalid log level: #{level}. Valid levels are: #{level_map.keys.join(', ')}"
103
+ end
104
+ else
105
+ raise ArgumentError, "Log level must be an Integer (0-5) or one of: debug, info, warn, error, fatal, unknown"
106
+ end
107
+ end
108
+ def handle_error(error, context = {})
109
+ if @error_handler
110
+ @error_handler.call(error, context)
111
+ else
112
+ default_error_handler.call(error, context)
113
+ end
114
+ end
115
+
116
+ def validate!
117
+ errors = []
118
+
119
+ # AWS Config
120
+ if aws_region.nil? || aws_region.empty?
121
+ errors << "aws_region is not configured"
122
+ end
123
+
124
+ # Validate thread and message settings before queue URLs
125
+ if !poller_thread_count.positive?
126
+ errors << "poller_thread_count must be positive"
127
+ end
128
+
129
+ if !max_number_of_messages.between?(1, 10)
130
+ errors << "max_number_of_messages must be between 1 and 10"
131
+ end
132
+
133
+ if !wait_time_seconds.between?(0, 20)
134
+ errors << "wait_time_seconds must be between 0 and 20"
135
+ end
136
+
137
+ if !visibility_timeout.positive?
138
+ errors << "visibility_timeout must be positive"
139
+ end
140
+
141
+ if ![:continuous, :scheduled].include?(polling_type)
142
+ errors << "polling_type must be :continuous or :scheduled"
143
+ end
144
+
145
+ if !polling_frequency.positive?
146
+ errors << "polling_frequency must be positive"
147
+ end
148
+
149
+ # Queue URLs should be checked last
150
+ if queue_urls.empty?
151
+ errors << "queue_urls is empty"
152
+ end
153
+
154
+ # Raise first specific error for single-error tests
155
+ if errors.length == 1
156
+ raise ArgumentError, errors.first
157
+ end
158
+
159
+ # Raise all errors together
160
+ if errors.any?
161
+ raise ArgumentError, "Invalid configuration: #{errors.join(', ')}"
162
+ end
163
+
164
+ true
165
+ end
166
+
167
+ private
168
+
169
+ def default_error_handler
170
+ ->(error, context = {}) do
171
+ logger = self.logger || Sidekiq.logger
172
+ if error.is_a?(Exception)
173
+ logger.error(error.message)
174
+ logger.error(error.backtrace.join("\n")) if error.backtrace
175
+ else
176
+ logger.error(error.to_s)
177
+ end
178
+ logger.error("Context: #{context.inspect}") unless context.empty?
179
+ end
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,128 @@
1
+ require 'singleton'
2
+
3
+ module SidekiqSqsProcessor
4
+ class ContinuousPoller
5
+ include Singleton
6
+
7
+ def initialize
8
+ @running = false
9
+ @threads = []
10
+ @mutex = Mutex.new
11
+ end
12
+
13
+ def start
14
+ return false if running?
15
+
16
+ @mutex.synchronize do
17
+ @running = true
18
+ start_polling_threads
19
+ end
20
+
21
+ true
22
+ end
23
+
24
+ def stop
25
+ return false unless running?
26
+
27
+ @mutex.synchronize do
28
+ @running = false
29
+ stop_polling_threads
30
+ end
31
+
32
+ true
33
+ end
34
+
35
+ def running?
36
+ @running
37
+ end
38
+
39
+ def stats
40
+ {
41
+ running: running?,
42
+ threads: @threads.count,
43
+ queue_urls: SidekiqSqsProcessor.configuration.queue_urls
44
+ }
45
+ end
46
+
47
+ private
48
+
49
+ def start_polling_threads
50
+ SidekiqSqsProcessor.configuration.queue_urls.each do |queue_url|
51
+ SidekiqSqsProcessor.configuration.poller_thread_count.times do
52
+ thread = Thread.new do
53
+ poll_queue(queue_url) while running?
54
+ end
55
+ @threads << thread
56
+ end
57
+ end
58
+ end
59
+
60
+ def stop_polling_threads
61
+ @threads.each(&:exit)
62
+ @threads.each(&:join)
63
+ @threads.clear
64
+ end
65
+
66
+ def poll_queue(queue_url)
67
+ response = receive_messages(queue_url)
68
+ process_messages(response.messages, queue_url)
69
+ rescue StandardError => e
70
+ SidekiqSqsProcessor.handle_error(e, { queue_url: queue_url })
71
+ sleep(1) # Brief pause before retrying
72
+ end
73
+
74
+ def receive_messages(queue_url)
75
+ SidekiqSqsProcessor.sqs_client.receive_message(
76
+ queue_url: queue_url,
77
+ max_number_of_messages: SidekiqSqsProcessor.configuration.max_number_of_messages,
78
+ visibility_timeout: SidekiqSqsProcessor.configuration.visibility_timeout,
79
+ wait_time_seconds: SidekiqSqsProcessor.configuration.wait_time_seconds,
80
+ attribute_names: ["All"],
81
+ message_attribute_names: ["All"]
82
+ )
83
+ end
84
+ def process_messages(messages, queue_url)
85
+ messages.each do |message|
86
+ message_data = nil
87
+ worker_class = nil
88
+
89
+ begin
90
+ # Convert to a hash for passing to the worker
91
+ message_data = {
92
+ "message_id" => message.message_id,
93
+ "receipt_handle" => message.receipt_handle,
94
+ "body" => message.body,
95
+ "attributes" => message.attributes,
96
+ "message_attributes" => message.message_attributes,
97
+ "md5_of_body" => message.md5_of_body,
98
+ "queue_url" => queue_url
99
+ }
100
+
101
+ worker_class = find_worker_for_message(message)
102
+ if worker_class
103
+ # Simply call perform_async, which will be handled appropriately in test vs prod
104
+ worker_class.perform_async(message_data)
105
+ end
106
+ rescue StandardError => e
107
+ # Handle worker error without re-raising
108
+ data_to_pass = message_data || message
109
+ handle_worker_error(e, worker_class&.name, data_to_pass, queue_url)
110
+ end
111
+ end
112
+ end
113
+
114
+ def handle_worker_error(error, worker_name, message, queue_url)
115
+ context = {
116
+ queue_url: queue_url,
117
+ worker: worker_name || "Unknown",
118
+ message: message
119
+ }
120
+ SidekiqSqsProcessor.handle_error(error, context)
121
+ end
122
+
123
+ def find_worker_for_message(message)
124
+ worker_name = "SqsProcessorWorker"
125
+ SidekiqSqsProcessor.find_worker_class(worker_name)
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,102 @@
1
+ require 'rails'
2
+ require 'sidekiq'
3
+
4
+ module SidekiqSqsProcessor
5
+ # Rails integration for SidekiqSqsProcessor
6
+ # Handles initialization, configuration, and lifecycle management
7
+ class Railtie < Rails::Railtie
8
+ initializer "sidekiq_sqs_processor.configure_rails_initialization" do |app|
9
+ # Set default logger to Rails logger if not specified
10
+ SidekiqSqsProcessor.configuration.logger ||= Rails.logger
11
+
12
+ # Disable polling in test environment by default
13
+ if Rails.env.test? && !ENV['ENABLE_SQS_POLLING_IN_TEST']
14
+ SidekiqSqsProcessor.configuration.polling_enabled = false
15
+ end
16
+
17
+ # Disable polling in development by default unless explicitly enabled
18
+ if Rails.env.development? && !ENV['ENABLE_SQS_POLLING_IN_DEV']
19
+ SidekiqSqsProcessor.configuration.polling_enabled = false
20
+ end
21
+
22
+ # Configure Sidekiq server middleware and lifecycle hooks
23
+ if defined?(Sidekiq)
24
+ Sidekiq.configure_server do |config|
25
+ # Start continuous poller when Sidekiq server starts
26
+ # Only if polling is enabled and type is continuous
27
+ config.on(:startup) do
28
+ if SidekiqSqsProcessor.configuration.polling_type == :continuous &&
29
+ SidekiqSqsProcessor.configuration.polling_enabled &&
30
+ SidekiqSqsProcessor.configuration.poll_on_startup
31
+
32
+ # Only run the poller in the scheduler process if using Sidekiq Enterprise
33
+ # For regular Sidekiq, this will run in every process
34
+ if !defined?(Sidekiq::Enterprise) || Sidekiq.schedule?
35
+ Rails.logger.info("Starting SidekiqSqsProcessor continuous poller")
36
+ SidekiqSqsProcessor.start_continuous_poller
37
+ end
38
+ end
39
+ end
40
+
41
+ # Stop continuous poller when Sidekiq server shuts down
42
+ config.on(:shutdown) do
43
+ if SidekiqSqsProcessor.continuous_poller_running?
44
+ Rails.logger.info("Stopping SidekiqSqsProcessor continuous poller")
45
+ SidekiqSqsProcessor.stop_continuous_poller
46
+ end
47
+ end
48
+ end
49
+ end
50
+
51
+ # Set up scheduled polling if enabled
52
+ if defined?(Sidekiq::Cron) &&
53
+ SidekiqSqsProcessor.configuration.polling_type == :scheduled &&
54
+ SidekiqSqsProcessor.configuration.polling_enabled
55
+
56
+ # Only set up in server mode
57
+ if Sidekiq.server?
58
+ frequency = SidekiqSqsProcessor.configuration.polling_frequency
59
+
60
+ # Convert seconds to cron expression
61
+ # Minimum 1 minute for cron
62
+ minutes = [frequency / 60, 1].max
63
+ cron_expression = minutes == 1 ? "* * * * *" : "*/#{minutes} * * * *"
64
+
65
+ # Create the cron job
66
+ Sidekiq::Cron::Job.create(
67
+ name: 'SQS Polling Job',
68
+ cron: cron_expression,
69
+ class: 'SidekiqSqsProcessor::ScheduledPoller',
70
+ queue: 'critical'
71
+ )
72
+
73
+ Rails.logger.info("Registered SidekiqSqsProcessor scheduled poller with cron: #{cron_expression}")
74
+ end
75
+ end
76
+ end
77
+
78
+ # Expose rake tasks if available
79
+ rake_tasks do
80
+ load "tasks/sidekiq_sqs_processor_tasks.rake" if File.exist?(File.join(File.dirname(__FILE__), "../tasks/sidekiq_sqs_processor_tasks.rake"))
81
+ end
82
+
83
+ # Register Rails generators
84
+ generators do
85
+ require_relative "../generators/sidekiq_sqs_processor/install_generator"
86
+ require_relative "../generators/sidekiq_sqs_processor/worker_generator"
87
+ end
88
+
89
+ # Add local configuration options to Rails application configuration
90
+ config.after_initialize do |app|
91
+ # Validate configuration if in production
92
+ if Rails.env.production? && SidekiqSqsProcessor.configuration.polling_enabled
93
+ # Ensure configuration is valid
94
+ unless SidekiqSqsProcessor.configuration.valid?
95
+ Rails.logger.error("Invalid SidekiqSqsProcessor configuration")
96
+ SidekiqSqsProcessor.configuration.validate! # This will raise an error with details
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
102
+
@@ -0,0 +1,5 @@
1
+
2
+ module SidekiqSqsProcessor
3
+ VERSION = "0.1.0"
4
+ end
5
+