aws-activejob-sqs 1.0.2 → 1.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 43f56ad87999ca924172fa56bb7fb6af16e77a0e63ed63cf77398e8ab097da7e
4
- data.tar.gz: 10b9fbb6d8583041306da87e6186e7ec67607f975b33242695080441db7a4ce3
3
+ metadata.gz: d1b770d150d33bd540df6ee1c10c1546ccd93590d7ad09706856e60f6b6cae47
4
+ data.tar.gz: 38cb019b97c6b630d396116b105a6c8df30c9f16318cf82cccd25f8f85fef587
5
5
  SHA512:
6
- metadata.gz: ed23b76c320bc0cf8860b9df133fd114fb62f5439dbca3eaacf927ffd6c30b7cf4eb2df05e74bacc1a79d56ffce1f77a6052a22c3ffd51fd1d7405c1bb29b45c
7
- data.tar.gz: 0ceaf002ad0b8ef3246df38b8570b2bbab13de7b53346dc8fefb49b964bd2d6db96fcc2a0f86c0c852048daee0202e2b8947f55267eab0f1a4c0a547d697c942
6
+ metadata.gz: 8a4fe7cc814506be5d6b667e44150c5abf6fd7badfaa565fc448bb976380e16ac594abc601333571faebc6ac964c885a98eebb67722476885f9f4d8d2ca01282
7
+ data.tar.gz: b4986768479fb9e0a92db2769a3a19b576184bcefe6491abe4aa501568d9aa8672988847587741cf391e4062d514a5f85a66f45919bdb7a8e13c8ebf026f7066
data/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ 1.1.0 (2026-08-11)
2
+ ------------------
3
+
4
+ * Issue - Raise a clear error at enqueue time when a delayed job targets a FIFO queue, which does not support per-message delays (#36).
5
+ * Issue - Only classes inheriting from `ActiveJob::Base` are executed by the poller, preventing arbitrary classes named in an SQS message from being instantiated and run. Job class names are validated as constant paths and resolved without searching the namespace's ancestors, so a message cannot name a constant outside the intended namespace.
6
+ * Feature - Add optional `job_class_allowlist` configuration to restrict which job classes can be dispatched. Configurable in code, the config YAML file, or via the `AWS_ACTIVE_JOB_SQS_JOB_CLASS_ALLOWLIST` environment variable. When set, the allowlist is checked before the class name is resolved, so an excluded class is never loaded.
7
+
1
8
  1.0.2 (2025-04-01)
2
9
  ------------------
3
10
 
data/VERSION CHANGED
@@ -1 +1 @@
1
- 1.0.2
1
+ 1.1.0
@@ -21,6 +21,7 @@ module ActiveJob
21
21
 
22
22
  def enqueue_at(job, timestamp)
23
23
  delay = Params.assured_delay_seconds(timestamp)
24
+ validate_fifo_delay!(job, delay)
24
25
  _enqueue(job, nil, delay_seconds: delay)
25
26
  end
26
27
 
@@ -45,22 +46,40 @@ module ActiveJob
45
46
  end
46
47
 
47
48
  def enqueue_batch(queue_url, chunk)
48
- entries = chunk.map do |job|
49
- entry = Params.new(job, nil).entry
50
- entry[:id] = job.job_id
51
- entry[:delay_seconds] = Params.assured_delay_seconds(job.scheduled_at) if job.scheduled_at
52
- entry
53
- end
54
-
55
49
  send_message_opts = {
56
50
  queue_url: queue_url,
57
- entries: entries
51
+ entries: chunk.map { |job| batch_entry(job) }
58
52
  }
59
53
 
60
54
  send_message_batch_result = Aws::ActiveJob::SQS.config.client.send_message_batch(send_message_opts)
61
55
  send_message_batch_result.successful.count
62
56
  end
63
57
 
58
+ def batch_entry(job)
59
+ entry = Params.new(job, nil).entry
60
+ entry[:id] = job.job_id
61
+ if job.scheduled_at
62
+ delay = Params.assured_delay_seconds(job.scheduled_at)
63
+ validate_fifo_delay!(job, delay)
64
+ entry[:delay_seconds] = delay
65
+ end
66
+ entry
67
+ end
68
+
69
+ # SQS FIFO queues do not support per-message delays
70
+ def validate_fifo_delay!(job, delay)
71
+ return unless delay.positive?
72
+
73
+ queue_url = Aws::ActiveJob::SQS.config.url_for(job.queue_name)
74
+ return unless Aws::ActiveJob::SQS.fifo?(queue_url)
75
+
76
+ err_msg =
77
+ "FIFO queue #{queue_url} does not support per-message delays " \
78
+ '(e.g. `set(wait:)`, `enqueue_at` or `retry_on wait:`). ' \
79
+ 'When using `retry_on` with FIFO queues, set `wait: 0`.'
80
+ raise Aws::ActiveJob::SQS::FifoDelayNotSupportedError, err_msg
81
+ end
82
+
64
83
  def _enqueue(job, body = nil, send_message_opts = {})
65
84
  body ||= job.serialize
66
85
  params = Params.new(job, body)
@@ -69,6 +69,7 @@ module Aws
69
69
  shutdown_timeout
70
70
  visibility_timeout
71
71
  message_group_id
72
+ job_class_allowlist
72
73
  ].freeze
73
74
 
74
75
  QUEUE_ENV_CONFIGS = %i[
@@ -81,7 +82,7 @@ module Aws
81
82
  QUEUE_CONFIGS = QUEUE_ENV_CONFIGS + %i[excluded_deduplication_keys]
82
83
 
83
84
  QUEUE_KEY_REGEX =
84
- /AWS_ACTIVE_JOB_SQS_([\w]+)_(#{QUEUE_ENV_CONFIGS.map(&:upcase).join('|')})/.freeze
85
+ /AWS_ACTIVE_JOB_SQS_(\w+)_(#{QUEUE_ENV_CONFIGS.map(&:upcase).join('|')})/.freeze
85
86
 
86
87
  # Don't use this method directly: Configuration is a singleton class,
87
88
  # use {Aws::ActiveJob::SQS.config Aws::ActiveJob::SQS.config}
@@ -148,6 +149,13 @@ module Aws
148
149
  # @option options [Array] :excluded_deduplication_keys (['job_id'])
149
150
  # The type of keys stored in the array should be String or Symbol.
150
151
  # Using this option, job_id is implicitly added to the keys.
152
+ #
153
+ # @option options [Array<Class, String>, String, nil] :job_class_allowlist (nil)
154
+ # An optional list of job classes permitted to be executed. When set,
155
+ # only classes in this list are dispatched; when nil, any class
156
+ # inheriting from ActiveJob::Base is allowed. Entries may be Class
157
+ # objects or class name Strings (in code/YAML), or a comma-separated
158
+ # String of class names (from ENV), and are compared by class name.
151
159
 
152
160
  def initialize(options = {})
153
161
  opts = env_options.deep_merge(options)
@@ -160,7 +168,8 @@ module Aws
160
168
  # @api private
161
169
  attr_accessor :queues, :threads, :backpressure,
162
170
  :shutdown_timeout, :logger,
163
- :async_queue_error_handler
171
+ :async_queue_error_handler,
172
+ :job_class_allowlist
164
173
 
165
174
  # @api private
166
175
  attr_writer :max_messages, :message_group_id, :visibility_timeout,
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aws
4
+ module ActiveJob
5
+ module SQS
6
+ # Raised when a job with a positive delay is enqueued to a FIFO queue.
7
+ class FifoDelayNotSupportedError < StandardError; end
8
+ end
9
+ end
10
+ end
@@ -91,13 +91,30 @@ module Aws
91
91
  job.run
92
92
  message.delete
93
93
  rescue JSON::ParserError => e
94
- @logger.error "Unable to parse message body: #{message.data.body}. Error: #{e}."
94
+ # An unparseable body is a permanent failure: the message content is
95
+ # immutable, so re-parsing it on redelivery can never succeed. Log and
96
+ # delete it so it does not redeliver indefinitely.
97
+ drop_permanent_failure(message, "Unable to parse message body: #{message.data.body}. Error: #{e}.")
98
+ rescue InvalidJobClassError => e
99
+ # A bad job class is a permanent failure: redelivering it can never
100
+ # succeed and, on the default config, would crash-loop the poller.
101
+ # Log the forensic detail (the message can't be inspected in a DLQ
102
+ # once deleted) and delete it so it does not redeliver.
103
+ drop_permanent_failure(message, "Rejecting message #{message.message_id}: #{e}. " \
104
+ "Body: #{message.data.body}. Deleting so it does not redeliver.")
95
105
  rescue StandardError => e
96
106
  handle_standard_error(e, job, message)
97
107
  ensure
98
108
  @task_complete.set
99
109
  end
100
110
 
111
+ # Log a permanently-failed message and delete it so SQS does not
112
+ # redeliver it (which, on the default config, would crash-loop the poller).
113
+ def drop_permanent_failure(message, log_message)
114
+ @logger.error log_message
115
+ message.delete
116
+ end
117
+
101
118
  def handle_standard_error(error, job, message)
102
119
  job_msg = job ? "#{job.id}[#{job.class_name}]" : 'unknown job'
103
120
  @logger.info "Error processing job #{job_msg}: #{error}"
@@ -3,13 +3,26 @@
3
3
  module Aws
4
4
  module ActiveJob
5
5
  module SQS
6
+ # Raised when a message names a job class that cannot be executed: the
7
+ # name is not a well-formed constant path, it is not in the configured
8
+ # job_class_allowlist, it is undefined, or it does not name a class
9
+ # inheriting from ActiveJob::Base. This is a permanent
10
+ # failure: such a message can never succeed, so the executor logs and
11
+ # deletes it rather than letting it redeliver.
12
+ class InvalidJobClassError < StandardError; end
13
+
6
14
  # @api private
7
15
  class JobRunner
16
+ # A job class name is an (optionally namespaced) constant path and
17
+ # nothing else. Names that cannot match this can never name a job
18
+ # class, so rejecting them keeps them out of the constant lookup.
19
+ JOB_CLASS_NAME_PATTERN = /\A[A-Z]\w*(::[A-Z]\w*)*\z/.freeze
20
+
8
21
  attr_reader :id, :class_name
9
22
 
10
23
  def initialize(message)
11
24
  @job_data = ActiveSupport::JSON.load(message.data.body)
12
- @class_name = @job_data['job_class'].constantize
25
+ @class_name = resolve_job_class(@job_data['job_class'])
13
26
  @id = @job_data['job_id']
14
27
  end
15
28
 
@@ -21,6 +34,53 @@ module Aws
21
34
  @job_data['exception_executions'] &&
22
35
  !@job_data['exception_executions'].empty?
23
36
  end
37
+
38
+ private
39
+
40
+ # Checks are ordered so that the least trusting one runs first. Merely
41
+ # resolving a constant is not inert: in Rails it triggers autoloading,
42
+ # which runs the body of whichever file defines it. So the name is
43
+ # matched against the allowlist as a String, before any lookup, and an
44
+ # excluded name never reaches the constant resolver at all.
45
+ def resolve_job_class(name)
46
+ name = name.to_s
47
+ unless JOB_CLASS_NAME_PATTERN.match?(name)
48
+ raise InvalidJobClassError, "#{name.inspect} is not a valid job class name"
49
+ end
50
+
51
+ allowlist = normalized_allowlist
52
+ if allowlist && !allowlist.include?(name)
53
+ raise InvalidJobClassError, "#{name} is not in the configured job_class_allowlist"
54
+ end
55
+
56
+ klass = constantize_job_class(name)
57
+ unless klass.is_a?(Class) && klass < ::ActiveJob::Base
58
+ raise InvalidJobClassError, "#{name} is not a valid job class (must inherit from ActiveJob::Base)"
59
+ end
60
+
61
+ klass
62
+ end
63
+
64
+ # Resolves an already-validated name. +inherit+ is false so that each
65
+ # segment must be defined directly on the preceding one: a name such
66
+ # as 'SomeJob::Foo' cannot resolve Foo from SomeJob's ancestors when
67
+ # SomeJob itself does not define it. NameError is treated as a
68
+ # rejection rather than propagated: an unknown class is the same
69
+ # permanent failure as a disallowed one.
70
+ def constantize_job_class(name)
71
+ Object.const_get(name, false)
72
+ rescue NameError
73
+ raise InvalidJobClassError, "#{name} is not defined"
74
+ end
75
+
76
+ # The allowlist may be configured as an Array of Class values (in code),
77
+ # an Array of Strings (from the YAML file), or a comma-separated String
78
+ # (from ENV). Normalize all forms to an Array of class name Strings.
79
+ def normalized_allowlist
80
+ allowlist = Aws::ActiveJob::SQS.config.job_class_allowlist
81
+ allowlist = allowlist.split(',') if allowlist.is_a?(String)
82
+ allowlist&.map { |entry| entry.to_s.strip }
83
+ end
24
84
  end
25
85
  end
26
86
  end
@@ -4,6 +4,7 @@ require 'active_job'
4
4
  require_relative 'active_job/queue_adapters/sqs_adapter'
5
5
  require_relative 'active_job/queue_adapters/sqs_adapter/params'
6
6
  require_relative 'active_job/queue_adapters/sqs_async_adapter'
7
+ require_relative 'aws/active_job/sqs/errors'
7
8
  require_relative 'aws/active_job/sqs/configuration'
8
9
  require_relative 'aws/active_job/sqs/deduplication'
9
10
  require_relative 'aws/active_job/sqs/executor'
@@ -16,24 +17,26 @@ module Aws
16
17
  module SQS
17
18
  VERSION = File.read(File.expand_path('../VERSION', __dir__)).strip
18
19
 
19
- # @return [Configuration] the (singleton) Configuration
20
- def self.config
21
- @config ||= Configuration.new
22
- end
20
+ class << self
21
+ # @return [Configuration] the (singleton) Configuration
22
+ def config
23
+ @config ||= Configuration.new
24
+ end
23
25
 
24
- # @yield [Configuration] the (singleton) Configuration
25
- def self.configure
26
- yield(config)
27
- end
26
+ # @yield [Configuration] the (singleton) Configuration
27
+ def configure
28
+ yield(config)
29
+ end
28
30
 
29
- # @param queue_url [String]
30
- # @return [Boolean] true if the queue_url is a FIFO queue
31
- def self.fifo?(queue_url)
32
- queue_url.end_with?('.fifo')
33
- end
31
+ # @param queue_url [String]
32
+ # @return [Boolean] true if the queue_url is a FIFO queue
33
+ def fifo?(queue_url)
34
+ queue_url.end_with?('.fifo')
35
+ end
34
36
 
35
- def self.on_worker_stop(...)
36
- Executor.on_stop(...)
37
+ def on_worker_stop(...)
38
+ Executor.on_stop(...)
39
+ end
37
40
  end
38
41
  end
39
42
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: aws-activejob-sqs
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.2
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Amazon Web Services
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-04-01 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: aws-sdk-sqs
@@ -76,6 +76,7 @@ files:
76
76
  - lib/aws/active_job/sqs/cli_options.rb
77
77
  - lib/aws/active_job/sqs/configuration.rb
78
78
  - lib/aws/active_job/sqs/deduplication.rb
79
+ - lib/aws/active_job/sqs/errors.rb
79
80
  - lib/aws/active_job/sqs/executor.rb
80
81
  - lib/aws/active_job/sqs/job_runner.rb
81
82
  - lib/aws/active_job/sqs/lambda_handler.rb
@@ -98,7 +99,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
98
99
  - !ruby/object:Gem::Version
99
100
  version: '0'
100
101
  requirements: []
101
- rubygems_version: 3.6.5
102
+ rubygems_version: 4.0.16
102
103
  specification_version: 4
103
104
  summary: ActiveJob integration with SQS
104
105
  test_files: []