activejob-temporal 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.
Files changed (99) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +130 -0
  3. data/LICENSE +21 -0
  4. data/README.md +198 -0
  5. data/activejob-temporal.gemspec +58 -0
  6. data/api/job_payload_schema.json +318 -0
  7. data/bin/temporal-worker +295 -0
  8. data/lib/activejob/temporal/active_job_handler_source.rb +84 -0
  9. data/lib/activejob/temporal/activities/aj_runner_activity.rb +454 -0
  10. data/lib/activejob/temporal/activities/best_effort_side_effects.rb +49 -0
  11. data/lib/activejob/temporal/activities/dependency_status_activity.rb +160 -0
  12. data/lib/activejob/temporal/activities/rate_limit_activity.rb +41 -0
  13. data/lib/activejob/temporal/adapter.rb +257 -0
  14. data/lib/activejob/temporal/audit_log.rb +118 -0
  15. data/lib/activejob/temporal/batch_enqueue_result.rb +110 -0
  16. data/lib/activejob/temporal/batch_enqueuer.rb +141 -0
  17. data/lib/activejob/temporal/bind_policy.rb +44 -0
  18. data/lib/activejob/temporal/cancel/batch_canceller.rb +154 -0
  19. data/lib/activejob/temporal/cancel/batch_summary.rb +45 -0
  20. data/lib/activejob/temporal/cancel.rb +236 -0
  21. data/lib/activejob/temporal/certificate_watcher.rb +76 -0
  22. data/lib/activejob/temporal/chain_options.rb +83 -0
  23. data/lib/activejob/temporal/child_workflow_options.rb +102 -0
  24. data/lib/activejob/temporal/client.rb +215 -0
  25. data/lib/activejob/temporal/conditional_enqueue.rb +56 -0
  26. data/lib/activejob/temporal/configurable.rb +55 -0
  27. data/lib/activejob/temporal/configuration.rb +981 -0
  28. data/lib/activejob/temporal/configured_job_compatibility.rb +44 -0
  29. data/lib/activejob/temporal/connection_worker_pool.rb +88 -0
  30. data/lib/activejob/temporal/dead_letter_payload_validation.rb +34 -0
  31. data/lib/activejob/temporal/dead_letter_queue.rb +163 -0
  32. data/lib/activejob/temporal/dependency_options.rb +134 -0
  33. data/lib/activejob/temporal/external_operation.rb +193 -0
  34. data/lib/activejob/temporal/health_check_server.rb +159 -0
  35. data/lib/activejob/temporal/http_line_reader.rb +36 -0
  36. data/lib/activejob/temporal/inspect.rb +184 -0
  37. data/lib/activejob/temporal/job_descriptor.rb +37 -0
  38. data/lib/activejob/temporal/job_payload_builder.rb +209 -0
  39. data/lib/activejob/temporal/job_payload_chain_builder.rb +106 -0
  40. data/lib/activejob/temporal/job_payload_child_workflows.rb +127 -0
  41. data/lib/activejob/temporal/job_payload_dependencies.rb +40 -0
  42. data/lib/activejob/temporal/job_payload_rate_limits.rb +53 -0
  43. data/lib/activejob/temporal/job_payload_workflow_interactions.rb +31 -0
  44. data/lib/activejob/temporal/job_tags.rb +40 -0
  45. data/lib/activejob/temporal/locales/en.yml +126 -0
  46. data/lib/activejob/temporal/logger.rb +214 -0
  47. data/lib/activejob/temporal/metrics_server.rb +150 -0
  48. data/lib/activejob/temporal/middleware/chain.rb +106 -0
  49. data/lib/activejob/temporal/middleware.rb +11 -0
  50. data/lib/activejob/temporal/observability/datadog.rb +167 -0
  51. data/lib/activejob/temporal/observability/opentelemetry.rb +107 -0
  52. data/lib/activejob/temporal/observability/prometheus.rb +271 -0
  53. data/lib/activejob/temporal/observability.rb +260 -0
  54. data/lib/activejob/temporal/payload.rb +415 -0
  55. data/lib/activejob/temporal/payload_encryption.rb +215 -0
  56. data/lib/activejob/temporal/payload_serializers/json.rb +23 -0
  57. data/lib/activejob/temporal/payload_serializers/marshal.rb +53 -0
  58. data/lib/activejob/temporal/payload_serializers/message_pack.rb +59 -0
  59. data/lib/activejob/temporal/payload_serializers.rb +37 -0
  60. data/lib/activejob/temporal/payload_storage.rb +103 -0
  61. data/lib/activejob/temporal/rails_environment_loader.rb +143 -0
  62. data/lib/activejob/temporal/rate_limit_options.rb +94 -0
  63. data/lib/activejob/temporal/rate_limiters/memory.rb +198 -0
  64. data/lib/activejob/temporal/reload_signal_queue.rb +40 -0
  65. data/lib/activejob/temporal/retry_handler_extractor.rb +361 -0
  66. data/lib/activejob/temporal/retry_mapper.rb +264 -0
  67. data/lib/activejob/temporal/schedulable.rb +60 -0
  68. data/lib/activejob/temporal/schedule.rb +181 -0
  69. data/lib/activejob/temporal/schedule_options.rb +105 -0
  70. data/lib/activejob/temporal/search_attributes.rb +173 -0
  71. data/lib/activejob/temporal/signal_query.rb +161 -0
  72. data/lib/activejob/temporal/signal_query_options.rb +106 -0
  73. data/lib/activejob/temporal/temporal_options.rb +114 -0
  74. data/lib/activejob/temporal/tls_file.rb +45 -0
  75. data/lib/activejob/temporal/transaction_safety.rb +39 -0
  76. data/lib/activejob/temporal/version.rb +7 -0
  77. data/lib/activejob/temporal/visibility_query.rb +13 -0
  78. data/lib/activejob/temporal/worker_client_reloader.rb +34 -0
  79. data/lib/activejob/temporal/worker_health.rb +117 -0
  80. data/lib/activejob/temporal/worker_pool.rb +408 -0
  81. data/lib/activejob/temporal/workflow_enqueuer.rb +271 -0
  82. data/lib/activejob/temporal/workflow_enqueuer_batch.rb +17 -0
  83. data/lib/activejob/temporal/workflow_id_builder.rb +155 -0
  84. data/lib/activejob/temporal/workflow_identity.rb +62 -0
  85. data/lib/activejob/temporal/workflows/aj_workflow.rb +282 -0
  86. data/lib/activejob/temporal/workflows/dead_letter_support.rb +134 -0
  87. data/lib/activejob/temporal/workflows/dead_letter_workflow.rb +114 -0
  88. data/lib/activejob/temporal/workflows/workflow_chaining.rb +194 -0
  89. data/lib/activejob/temporal/workflows/workflow_child_workflows.rb +140 -0
  90. data/lib/activejob/temporal/workflows/workflow_continue_as_new.rb +44 -0
  91. data/lib/activejob/temporal/workflows/workflow_dependencies.rb +115 -0
  92. data/lib/activejob/temporal/workflows/workflow_execution_steps.rb +22 -0
  93. data/lib/activejob/temporal/workflows/workflow_interactions.rb +215 -0
  94. data/lib/activejob/temporal/workflows/workflow_local_activities.rb +29 -0
  95. data/lib/activejob/temporal/workflows/workflow_nexus.rb +15 -0
  96. data/lib/activejob/temporal/workflows/workflow_versioning.rb +21 -0
  97. data/lib/activejob/temporal.rb +297 -0
  98. data/lib/activejob-temporal.rb +3 -0
  99. metadata +423 -0
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveJob
4
+ module Temporal
5
+ class ReloadSignalQueue
6
+ POLL_INTERVAL_SECONDS = 0.05
7
+
8
+ def initialize
9
+ @pending_signal = nil
10
+ @closed = false
11
+ end
12
+
13
+ def push(signal)
14
+ return if @closed || @pending_signal
15
+
16
+ @pending_signal = signal
17
+ signal
18
+ end
19
+
20
+ def pop
21
+ loop do
22
+ return nil if @closed
23
+
24
+ if @pending_signal
25
+ signal = @pending_signal
26
+ @pending_signal = nil
27
+ return signal
28
+ end
29
+
30
+ sleep(POLL_INTERVAL_SECONDS)
31
+ end
32
+ end
33
+
34
+ def close
35
+ @closed = true
36
+ @pending_signal = nil
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,361 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/string/inflections"
4
+ require_relative "active_job_handler_source"
5
+ require_relative "logger"
6
+
7
+ module ActiveJob
8
+ module Temporal
9
+ # Extracts ActiveJob retry_on and discard_on handler declarations.
10
+ #
11
+ # This class introspects an ActiveJob class to find all retry_on and
12
+ # discard_on declarations, converting them into structured handler objects.
13
+ # It handles the complexity of ActiveJob's internal rescue_handlers mechanism,
14
+ # including binding inspection and exception class constantization.
15
+ #
16
+ # The extractor is used by RetryMapper to separate handler extraction logic
17
+ # from retry policy building logic, improving testability and maintainability.
18
+ #
19
+ # @note ActiveJob Compatibility
20
+ # ActiveJob does not expose retry_on or discard_on metadata through a
21
+ # public API. If retry metadata cannot be read, the extractor logs a warning
22
+ # and returns nil retry values so RetryMapper can use configured defaults
23
+ # instead of failing during enqueue.
24
+ #
25
+ # @example Extracting retry handlers
26
+ # extractor = RetryHandlerExtractor.new
27
+ # retry_handlers = extractor.retry_handlers(MyJob)
28
+ # # => [{ exception: StandardError, wait: 5.seconds, attempts: 3, handler: ... }]
29
+ #
30
+ # @example Extracting discard handlers
31
+ # discard_handlers = extractor.discard_handlers(MyJob)
32
+ # # => [{ exception: ActiveRecord::RecordNotFound, handler: ... }]
33
+ # rubocop:disable Metrics/ClassLength
34
+ class RetryHandlerExtractor
35
+ def initialize
36
+ @cache_mutex = Mutex.new
37
+ @retry_handlers_by_job_class = ObjectSpace::WeakMap.new
38
+ @discard_handlers_by_job_class = ObjectSpace::WeakMap.new
39
+ end
40
+
41
+ # Extracts retry handler entries from a job class's rescue_handlers.
42
+ #
43
+ # Iterates through the job class's rescue_handlers and filters for retry_on
44
+ # declarations. Binding metadata is used when available; source-location
45
+ # fallback keeps enqueue working if ActiveJob changes closure locals.
46
+ #
47
+ # @param job_class [Class] ActiveJob class with retry_on declarations
48
+ #
49
+ # @return [Array<Hash>] Array of retry handler entries, each containing:
50
+ # - :exception [Class] Exception class to match
51
+ # - :wait [Numeric, Symbol, Proc] Wait strategy (duration, :exponentially_longer, etc.)
52
+ # - :attempts [Integer, Symbol] Max attempts (number or :unlimited)
53
+ # - :handler [Proc] The raw handler proc from ActiveJob
54
+ #
55
+ # @example Basic retry handler
56
+ # class MyJob < ActiveJob::Base
57
+ # retry_on StandardError, wait: 5.seconds, attempts: 3
58
+ # end
59
+ #
60
+ # extractor = RetryHandlerExtractor.new
61
+ # handlers = extractor.retry_handlers(MyJob)
62
+ # # => [{ exception: StandardError, wait: 5.0, attempts: 3, handler: #<Proc> }]
63
+ def retry_handlers(job_class)
64
+ cached_handler_entries(@retry_handlers_by_job_class, job_class) do |handlers|
65
+ handler_entries(job_class, handlers) do |class_or_name, handler|
66
+ retry_handler_payload(job_class, class_or_name, handler)
67
+ end
68
+ end
69
+ end
70
+
71
+ # Extracts discard handler entries from a job class's rescue_handlers.
72
+ #
73
+ # Iterates through the job class's rescue_handlers and filters for discard_on
74
+ # declarations. Returns structured handler entries with exception classes
75
+ # that should not be retried.
76
+ #
77
+ # @param job_class [Class] ActiveJob class with discard_on declarations
78
+ #
79
+ # @return [Array<Hash>] Array of discard handler entries, each containing:
80
+ # - :exception [Class] Exception class to discard (not retry)
81
+ # - :handler [Proc] The raw handler proc from ActiveJob
82
+ #
83
+ # @example Basic discard handler
84
+ # class MyJob < ActiveJob::Base
85
+ # discard_on ActiveRecord::RecordNotFound
86
+ # end
87
+ #
88
+ # extractor = RetryHandlerExtractor.new
89
+ # handlers = extractor.discard_handlers(MyJob)
90
+ # # => [{ exception: ActiveRecord::RecordNotFound, handler: #<Proc> }]
91
+ def discard_handlers(job_class)
92
+ cached_handler_entries(@discard_handlers_by_job_class, job_class) do |handlers|
93
+ handler_entries(job_class, handlers) do |class_or_name, handler|
94
+ discard_handler_payload(job_class, class_or_name, handler)
95
+ end
96
+ end
97
+ end
98
+
99
+ # Checks if an exception should be discarded based on job class handlers.
100
+ #
101
+ # Inspects the job class's discard_on declarations to determine if the given
102
+ # exception matches any discard handler. This is used to determine if an
103
+ # activity should raise a non-retryable error in Temporal.
104
+ #
105
+ # @param job_class [Class] ActiveJob class with discard_on declarations
106
+ # @param exception [Exception] Exception instance to check
107
+ #
108
+ # @return [Boolean] true if exception should be discarded, false otherwise
109
+ #
110
+ # @example Check if exception is discardable
111
+ # class MyJob < ApplicationJob
112
+ # discard_on ActiveRecord::RecordNotFound
113
+ # end
114
+ #
115
+ # extractor = RetryHandlerExtractor.new
116
+ # extractor.discard_exception?(MyJob, ActiveRecord::RecordNotFound.new)
117
+ # # => true
118
+ def discard_exception?(job_class, exception)
119
+ return false unless job_class && exception
120
+
121
+ discard_handlers(job_class).any? do |handler|
122
+ handles_exception?(handler[:exception], exception)
123
+ end
124
+ end
125
+
126
+ private
127
+
128
+ def cached_handler_entries(cache, job_class)
129
+ return [] unless job_class.respond_to?(:rescue_handlers)
130
+
131
+ handlers = job_class.rescue_handlers
132
+ return [] unless handlers.respond_to?(:reverse_each)
133
+
134
+ signature = rescue_handlers_signature(handlers)
135
+
136
+ @cache_mutex.synchronize do
137
+ cached = cache[job_class]
138
+ return cached[:entries] if cached && cached[:signature] == signature
139
+
140
+ entries = yield(handlers).map(&:freeze).freeze
141
+ cache[job_class] = { signature: signature, entries: entries }.freeze
142
+ entries
143
+ end
144
+ end
145
+
146
+ def rescue_handlers_signature(handlers)
147
+ handlers.reverse_each.map do |class_or_name, handler|
148
+ [class_or_name, handler.object_id]
149
+ end
150
+ end
151
+
152
+ # Generic handler entry extractor (used by retry_handlers and discard_handlers).
153
+ #
154
+ # Walks through the job class's rescue_handlers in reverse order (ActiveJob's
155
+ # precedence order: last declared = first matched) and yields each handler to
156
+ # the provided block for filtering and transformation.
157
+ #
158
+ # @api private
159
+ # @param job_class [Class] ActiveJob class
160
+ # @yield [handler] Block that filters and transforms each handler
161
+ # @return [Array<Hash>] Filtered and transformed handler entries
162
+ def handler_entries(job_class, handlers)
163
+ entries = []
164
+ handlers.reverse_each do |class_or_name, handler|
165
+ payload = yield(class_or_name, handler)
166
+ next unless payload
167
+
168
+ exception_class = constantize_handler_class(job_class, class_or_name)
169
+ next unless exception_class
170
+
171
+ entries << payload.merge(exception: exception_class)
172
+ end
173
+ entries
174
+ end
175
+
176
+ def retry_handler_payload(job_class, class_or_name, handler)
177
+ unless ActiveJobHandlerSource.supported?(:retry_on)
178
+ log_handler_source_unavailable("retry", job_class)
179
+ return nil
180
+ end
181
+
182
+ return nil unless retry_handler_source?(job_class, handler)
183
+
184
+ binding = handler_binding(handler)
185
+ payload = retry_payload_from_binding(binding)
186
+ return payload.merge(handler: handler) if payload.is_a?(Hash)
187
+
188
+ log_metadata_fallback("retry", job_class, class_or_name, "retry_on_metadata_unavailable")
189
+
190
+ {
191
+ handler: handler,
192
+ wait: fallback_binding_value(binding, :wait),
193
+ attempts: fallback_binding_value(binding, :attempts)
194
+ }
195
+ end
196
+
197
+ def discard_handler_payload(job_class, class_or_name, handler)
198
+ unless ActiveJobHandlerSource.supported?(:discard_on)
199
+ log_handler_source_unavailable("discard", job_class)
200
+ return nil
201
+ end
202
+
203
+ return nil unless discard_handler_source?(job_class, handler)
204
+
205
+ binding = handler_binding(handler)
206
+ return { handler: handler } if discard_handler_binding?(binding)
207
+
208
+ log_metadata_fallback("discard", job_class, class_or_name, "discard_on_metadata_unavailable")
209
+
210
+ { handler: handler }
211
+ end
212
+
213
+ def handler_binding(handler)
214
+ return nil unless handler.respond_to?(:binding)
215
+
216
+ handler.binding
217
+ rescue StandardError
218
+ nil
219
+ end
220
+
221
+ def retry_payload_from_binding(binding)
222
+ return nil unless binding
223
+ return nil unless local_variable_defined?(binding, :attempts)
224
+
225
+ {
226
+ wait: binding.local_variable_get(:wait),
227
+ attempts: binding.local_variable_get(:attempts)
228
+ }.merge(exception_execution_key_from_binding(binding))
229
+ rescue StandardError
230
+ nil
231
+ end
232
+
233
+ def exception_execution_key_from_binding(binding)
234
+ return {} unless local_variable_defined?(binding, :exceptions)
235
+
236
+ { exception_execution_key: binding.local_variable_get(:exceptions).to_s }
237
+ rescue StandardError
238
+ {}
239
+ end
240
+
241
+ def fallback_binding_value(binding, name)
242
+ return nil unless binding
243
+ return nil unless local_variable_defined?(binding, name)
244
+
245
+ binding.local_variable_get(name)
246
+ rescue StandardError
247
+ nil
248
+ end
249
+
250
+ def retry_handler_source?(job_class, handler)
251
+ handler_source_match?("retry", job_class, handler, :retry_on)
252
+ end
253
+
254
+ def discard_handler_source?(job_class, handler)
255
+ handler_source_match?("discard", job_class, handler, :discard_on)
256
+ end
257
+
258
+ def handler_source_match?(handler_type, job_class, handler, method_name)
259
+ case ActiveJobHandlerSource.match_status(handler, method_name)
260
+ when :match
261
+ true
262
+ when :unsupported
263
+ log_handler_source_unavailable(handler_type, job_class)
264
+ false
265
+ else
266
+ false
267
+ end
268
+ end
269
+
270
+ def log_metadata_fallback(handler_type, job_class, class_or_name, reason)
271
+ warning_key = [handler_type, job_class.name, class_or_name.to_s, reason]
272
+ @metadata_fallback_warnings ||= {}
273
+ return if @metadata_fallback_warnings[warning_key]
274
+
275
+ @metadata_fallback_warnings[warning_key] = true
276
+ ActiveJob::Temporal::Logger.warn(
277
+ "active_job_handler_metadata_fallback",
278
+ handler_type: handler_type,
279
+ job_class: job_class.name,
280
+ exception: class_or_name.to_s,
281
+ reason: reason
282
+ )
283
+ rescue StandardError
284
+ nil
285
+ end
286
+
287
+ def log_handler_source_unavailable(handler_type, job_class)
288
+ warning_key = [handler_type, job_class.name, "handler_source_unavailable"]
289
+ @metadata_fallback_warnings ||= {}
290
+ return if @metadata_fallback_warnings[warning_key]
291
+
292
+ @metadata_fallback_warnings[warning_key] = true
293
+ ActiveJob::Temporal::Logger.warn(
294
+ "active_job_handler_source_unavailable",
295
+ handler_type: handler_type,
296
+ job_class: job_class.name,
297
+ reason: "active_job_handler_source_unavailable"
298
+ )
299
+ rescue StandardError
300
+ nil
301
+ end
302
+
303
+ # Checks if a binding represents a discard handler.
304
+ #
305
+ # Current ActiveJob exposes :report in discard_on handler bindings. Older
306
+ # versions did not, so source-location fallback handles those declarations.
307
+ #
308
+ # @api private
309
+ # @param binding [Binding] Handler proc's binding
310
+ # @return [Boolean] true if binding represents a discard handler
311
+ def discard_handler_binding?(binding)
312
+ local_variable_defined?(binding, :report) && !local_variable_defined?(binding, :attempts)
313
+ end
314
+
315
+ def local_variable_defined?(binding, name)
316
+ binding&.local_variable_defined?(name)
317
+ rescue StandardError
318
+ false
319
+ end
320
+
321
+ # Constantizes exception class from symbol/string/module.
322
+ #
323
+ # ActiveJob stores exception handlers with various types (Module, String, Symbol).
324
+ # This method normalizes them to actual exception classes, handling both
325
+ # relative constants (defined in job class) and global constants.
326
+ #
327
+ # @api private
328
+ # @param job_class [Class] Job class for relative constant lookup
329
+ # @param class_or_name [Module, String, Symbol] Exception class or name
330
+ # @return [Class, nil] Constantized exception class, or nil if not found
331
+ def constantize_handler_class(job_class, class_or_name)
332
+ case class_or_name
333
+ when Module
334
+ class_or_name
335
+ when String, Symbol
336
+ job_class.const_get(class_or_name)
337
+ end
338
+ rescue NameError
339
+ class_or_name.to_s.safe_constantize
340
+ end
341
+
342
+ # Checks if handler_class handles the given exception.
343
+ #
344
+ # Uses Ruby's inheritance mechanism to check if the exception is an instance
345
+ # of (or subclass of) the handler's exception class. Supports both exception
346
+ # instances and exception classes.
347
+ #
348
+ # @api private
349
+ # @param handler_class [Class] Exception class from handler
350
+ # @param exception [Exception, Class] Exception to check
351
+ # @return [Boolean] true if handler_class handles the exception
352
+ def handles_exception?(handler_class, exception)
353
+ return false unless handler_class && exception
354
+
355
+ candidate_class = exception.is_a?(Module) ? exception : exception.class
356
+ candidate_class <= handler_class
357
+ end
358
+ end
359
+ # rubocop:enable Metrics/ClassLength
360
+ end
361
+ end
@@ -0,0 +1,264 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/string/inflections"
4
+ require_relative "retry_handler_extractor"
5
+
6
+ module ActiveJob
7
+ module Temporal
8
+ # Translates ActiveJob retry DSL to Temporal RetryPolicy.
9
+ #
10
+ # This module introspects a job class's `retry_on` and `discard_on` declarations
11
+ # and converts them into a Temporal-compatible retry policy hash. It handles:
12
+ # - Retry intervals (wait durations)
13
+ # - Retry attempt limits
14
+ # - Non-retryable error types (from discard_on)
15
+ #
16
+ # The mapper uses Ruby's internal `rescue_handlers` mechanism to extract retry
17
+ # configuration at runtime, ensuring compatibility with ActiveJob's DSL.
18
+ #
19
+ # @note Algorithmic Wait Values
20
+ # If `retry_on` uses a Proc or Symbol for `:wait` (e.g., `:exponentially_longer`),
21
+ # it falls back to the configured `default_retry_initial_interval` because Temporal
22
+ # only accepts static numeric intervals. Temporal's built-in exponential backoff
23
+ # (via `backoff_coefficient`) is used instead.
24
+ #
25
+ # @note Multiple retry_on Declarations
26
+ # If a job class has multiple `retry_on` declarations, the first matching handler
27
+ # (based on exception type) is used. Handlers are evaluated in reverse order of
28
+ # declaration (last declared = first matched).
29
+ #
30
+ # @note Exponential Backoff
31
+ # Temporal automatically applies exponential backoff using backoff_coefficient.
32
+ # For example, with initial_interval=30s and backoff_coefficient=2.0, retries occur
33
+ # at 30s, 60s, 120s, 240s intervals (exponentially increasing).
34
+ #
35
+ # @note Unlimited Retries
36
+ # Setting attempts: :unlimited translates to maximum_attempts: 0 in Temporal,
37
+ # which means the activity will retry indefinitely until it succeeds or is cancelled.
38
+ # Use this carefully to avoid infinite retry loops.
39
+ #
40
+ # @note Exception Inheritance
41
+ # Exception matching respects inheritance. A retry_on StandardError declaration
42
+ # will match all StandardError subclasses (RuntimeError, ArgumentError, etc.).
43
+ # More specific exceptions should be declared last to take precedence.
44
+ #
45
+ # @example Retry policy structure
46
+ # {
47
+ # initial_interval: 30.0, # seconds (Float)
48
+ # backoff_coefficient: 2.0, # exponential backoff multiplier
49
+ # maximum_attempts: 5, # max retry count (0 = unlimited)
50
+ # non_retryable_error_types: ["MyError::ClassName"]
51
+ # }
52
+ #
53
+ # @see https://docs.temporal.io/retry-policies Temporal Retry Policies
54
+ # @see https://edgeguides.rubyonrails.org/active_job_basics.html#retrying-or-discarding-failed-jobs
55
+ # ActiveJob Retry Guide
56
+ module RetryMapper
57
+ module_function
58
+
59
+ # Builds a Temporal retry policy hash from a job class's retry configuration.
60
+ #
61
+ # Inspects the job class's `retry_on` declarations and constructs a retry policy.
62
+ # If an exception is provided, selects the matching retry handler; otherwise uses
63
+ # the first retry handler found.
64
+ #
65
+ # @param job_class [Class] ActiveJob class with retry_on/discard_on declarations
66
+ # @param exception [Exception, nil] Optional exception to match against handlers
67
+ #
68
+ # @return [Hash] Retry policy with keys:
69
+ # - :initial_interval [Float] Initial retry delay in seconds
70
+ # - :backoff_coefficient [Float] Exponential backoff multiplier
71
+ # - :maximum_attempts [Integer] Max retry attempts (0 = unlimited)
72
+ # - :non_retryable_error_types [Array<String>] Exception class names to not retry
73
+ #
74
+ # @raise [TypeError] if attempts value cannot be converted to Integer
75
+ #
76
+ # @example Basic retry policy
77
+ # class MyJob < ApplicationJob
78
+ # retry_on StandardError, wait: 5.seconds, attempts: 3
79
+ # end
80
+ # RetryMapper.for(MyJob)
81
+ # # => { initial_interval: 5.0, backoff_coefficient: 2.0, maximum_attempts: 3, ... }
82
+ #
83
+ # @example Unlimited retries
84
+ # class MyJob < ApplicationJob
85
+ # retry_on NetworkError, wait: 30.seconds, attempts: :unlimited
86
+ # end
87
+ # RetryMapper.for(MyJob)
88
+ # # => { ..., maximum_attempts: 0 }
89
+ #
90
+ # @example Multiple retry_on declarations (precedence)
91
+ # class MyJob < ApplicationJob
92
+ # retry_on StandardError, wait: 10.seconds, attempts: 5
93
+ # retry_on Timeout::Error, wait: 1.second, attempts: 10
94
+ # end
95
+ # RetryMapper.for(MyJob, Timeout::Error.new)
96
+ # # => { initial_interval: 1.0, maximum_attempts: 10, ... }
97
+ #
98
+ # @example With discard_on (non-retryable errors)
99
+ # class MyJob < ApplicationJob
100
+ # retry_on StandardError, wait: 5.seconds, attempts: 3
101
+ # discard_on ActiveRecord::RecordNotFound
102
+ # end
103
+ # RetryMapper.for(MyJob)
104
+ # # => { ..., non_retryable_error_types: ["ActiveRecord::RecordNotFound"] }
105
+ #
106
+ # @example Algorithmic wait (falls back to default)
107
+ # class MyJob < ApplicationJob
108
+ # retry_on NetworkError, wait: :exponentially_longer, attempts: 5
109
+ # end
110
+ # RetryMapper.for(MyJob)
111
+ # # => { initial_interval: 30.0, backoff_coefficient: 2.0, maximum_attempts: 5, ... }
112
+ # # (Uses config.default_retry_initial_interval because :exponentially_longer is not a static value)
113
+ #
114
+ # @note Precedence of Multiple retry_on Declarations
115
+ # When a job class has multiple retry_on declarations, ActiveJob's rescue_handlers
116
+ # are evaluated in reverse order (last declared = first matched). If you provide an
117
+ # exception argument, the first matching handler is used. Without an exception, the
118
+ # first handler in the list is used.
119
+ def for(job_class, exception = nil)
120
+ config = ActiveJob::Temporal.config
121
+ retry_entries = extractor.retry_handlers(job_class)
122
+ retry_entry = select_retry_entry(job_class, exception, retry_entries)
123
+
124
+ {
125
+ initial_interval: interval_from(retry_entry&.fetch(:wait, nil), config),
126
+ backoff_coefficient: config.default_retry_backoff,
127
+ maximum_attempts: maximum_attempts_from(retry_entries, retry_entry, exception, config, job_class),
128
+ non_retryable_error_types: discard_exception_names(job_class)
129
+ }
130
+ end
131
+
132
+ def retry_handler(job_class, exception)
133
+ select_retry_entry(job_class, exception)
134
+ end
135
+
136
+ def exception_execution_keys(job_class)
137
+ extractor.retry_handlers(job_class).filter_map { |handler| handler[:exception_execution_key] }.uniq
138
+ end
139
+
140
+ # Checks if an exception should be discarded (not retried).
141
+ #
142
+ # Inspects the job class's `discard_on` declarations to determine if the given
143
+ # exception matches any discard handler. If true, the activity should raise
144
+ # a non-retryable error to stop Temporal from retrying.
145
+ #
146
+ # @param job_class [Class] ActiveJob class with discard_on declarations
147
+ # @param exception [Exception] Exception instance to check
148
+ #
149
+ # @return [Boolean] true if exception should be discarded, false otherwise
150
+ #
151
+ # @example Check if exception is discardable
152
+ # class MyJob < ApplicationJob
153
+ # discard_on ActiveRecord::RecordNotFound
154
+ # end
155
+ # RetryMapper.discard_exception?(MyJob, ActiveRecord::RecordNotFound.new)
156
+ # # => true
157
+ def discard_exception?(job_class, exception)
158
+ extractor.discard_exception?(job_class, exception)
159
+ end
160
+
161
+ # -- helpers ----------------------------------------------------------------
162
+
163
+ # Returns the handler extractor instance (memoized).
164
+ # @api private
165
+ def extractor
166
+ @extractor ||= RetryHandlerExtractor.new
167
+ end
168
+ private_class_method :extractor
169
+
170
+ # Selects the matching retry handler entry for the given exception.
171
+ # @api private
172
+ def select_retry_entry(job_class, exception, handlers = extractor.retry_handlers(job_class))
173
+ return nil if handlers.empty?
174
+
175
+ return handlers.find { |handler| handles_exception?(handler[:exception], exception) } if exception
176
+
177
+ handlers.first
178
+ end
179
+ private_class_method :select_retry_entry
180
+
181
+ # Extracts discard handler exception class names.
182
+ # @api private
183
+ def discard_exception_names(job_class)
184
+ extractor.discard_handlers(job_class).each_with_object([]) do |handler, names|
185
+ next unless handler[:exception]
186
+
187
+ name = handler[:exception].name
188
+ next unless name
189
+ next if names.include?(name)
190
+
191
+ names << name
192
+ end
193
+ end
194
+ private_class_method :discard_exception_names
195
+
196
+ # Extracts initial interval from retry_on wait value.
197
+ # @api private
198
+ def interval_from(value, config)
199
+ # Temporal only accepts numeric intervals, so algorithmic waits (Proc/Symbol)
200
+ # fall back to the configured default for now.
201
+ case value
202
+ when Numeric, ActiveSupport::Duration
203
+ value.to_f
204
+ else
205
+ config.default_retry_initial_interval.to_f
206
+ end
207
+ end
208
+ private_class_method :interval_from
209
+
210
+ # Extracts maximum attempts from retry_on attempts value.
211
+ # @api private
212
+ def attempts_from(value, config, job_class)
213
+ case value
214
+ when nil
215
+ config.default_retry_max_attempts
216
+ when :unlimited
217
+ 0
218
+ else
219
+ Integer(value)
220
+ end
221
+ rescue ArgumentError, TypeError => e
222
+ log_attempts_fallback(job_class, value, config.default_retry_max_attempts, e)
223
+ config.default_retry_max_attempts
224
+ end
225
+ private_class_method :attempts_from
226
+
227
+ def maximum_attempts_from(retry_entries, retry_entry, exception, config, job_class)
228
+ return attempts_from(retry_entry&.fetch(:attempts, nil), config, job_class) if exception || retry_entries.empty?
229
+
230
+ attempts = retry_entries.map do |entry|
231
+ attempts_from(entry.fetch(:attempts, nil), config, job_class)
232
+ end
233
+ return 0 if attempts.include?(0)
234
+
235
+ attempts.max || config.default_retry_max_attempts
236
+ end
237
+ private_class_method :maximum_attempts_from
238
+
239
+ def log_attempts_fallback(job_class, value, default_attempts, error)
240
+ ActiveJob::Temporal::Logger.warn(
241
+ "retry_attempts_fallback",
242
+ job_class: job_class&.name,
243
+ attempts: value.inspect,
244
+ default_attempts: default_attempts,
245
+ error_class: error.class.name
246
+ )
247
+ rescue StandardError
248
+ nil
249
+ end
250
+ private_class_method :log_attempts_fallback
251
+
252
+ # Checks if handler_class handles the given exception.
253
+ # Delegates to the extractor's private method via duck typing.
254
+ # @api private
255
+ def handles_exception?(handler_class, exception)
256
+ return false unless handler_class && exception
257
+
258
+ candidate_class = exception.is_a?(Module) ? exception : exception.class
259
+ candidate_class <= handler_class
260
+ end
261
+ private_class_method :handles_exception?
262
+ end
263
+ end
264
+ end