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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +130 -0
- data/LICENSE +21 -0
- data/README.md +198 -0
- data/activejob-temporal.gemspec +58 -0
- data/api/job_payload_schema.json +318 -0
- data/bin/temporal-worker +295 -0
- data/lib/activejob/temporal/active_job_handler_source.rb +84 -0
- data/lib/activejob/temporal/activities/aj_runner_activity.rb +454 -0
- data/lib/activejob/temporal/activities/best_effort_side_effects.rb +49 -0
- data/lib/activejob/temporal/activities/dependency_status_activity.rb +160 -0
- data/lib/activejob/temporal/activities/rate_limit_activity.rb +41 -0
- data/lib/activejob/temporal/adapter.rb +257 -0
- data/lib/activejob/temporal/audit_log.rb +118 -0
- data/lib/activejob/temporal/batch_enqueue_result.rb +110 -0
- data/lib/activejob/temporal/batch_enqueuer.rb +141 -0
- data/lib/activejob/temporal/bind_policy.rb +44 -0
- data/lib/activejob/temporal/cancel/batch_canceller.rb +154 -0
- data/lib/activejob/temporal/cancel/batch_summary.rb +45 -0
- data/lib/activejob/temporal/cancel.rb +236 -0
- data/lib/activejob/temporal/certificate_watcher.rb +76 -0
- data/lib/activejob/temporal/chain_options.rb +83 -0
- data/lib/activejob/temporal/child_workflow_options.rb +102 -0
- data/lib/activejob/temporal/client.rb +215 -0
- data/lib/activejob/temporal/conditional_enqueue.rb +56 -0
- data/lib/activejob/temporal/configurable.rb +55 -0
- data/lib/activejob/temporal/configuration.rb +981 -0
- data/lib/activejob/temporal/configured_job_compatibility.rb +44 -0
- data/lib/activejob/temporal/connection_worker_pool.rb +88 -0
- data/lib/activejob/temporal/dead_letter_payload_validation.rb +34 -0
- data/lib/activejob/temporal/dead_letter_queue.rb +163 -0
- data/lib/activejob/temporal/dependency_options.rb +134 -0
- data/lib/activejob/temporal/external_operation.rb +193 -0
- data/lib/activejob/temporal/health_check_server.rb +159 -0
- data/lib/activejob/temporal/http_line_reader.rb +36 -0
- data/lib/activejob/temporal/inspect.rb +184 -0
- data/lib/activejob/temporal/job_descriptor.rb +37 -0
- data/lib/activejob/temporal/job_payload_builder.rb +209 -0
- data/lib/activejob/temporal/job_payload_chain_builder.rb +106 -0
- data/lib/activejob/temporal/job_payload_child_workflows.rb +127 -0
- data/lib/activejob/temporal/job_payload_dependencies.rb +40 -0
- data/lib/activejob/temporal/job_payload_rate_limits.rb +53 -0
- data/lib/activejob/temporal/job_payload_workflow_interactions.rb +31 -0
- data/lib/activejob/temporal/job_tags.rb +40 -0
- data/lib/activejob/temporal/locales/en.yml +126 -0
- data/lib/activejob/temporal/logger.rb +214 -0
- data/lib/activejob/temporal/metrics_server.rb +150 -0
- data/lib/activejob/temporal/middleware/chain.rb +106 -0
- data/lib/activejob/temporal/middleware.rb +11 -0
- data/lib/activejob/temporal/observability/datadog.rb +167 -0
- data/lib/activejob/temporal/observability/opentelemetry.rb +107 -0
- data/lib/activejob/temporal/observability/prometheus.rb +271 -0
- data/lib/activejob/temporal/observability.rb +260 -0
- data/lib/activejob/temporal/payload.rb +415 -0
- data/lib/activejob/temporal/payload_encryption.rb +215 -0
- data/lib/activejob/temporal/payload_serializers/json.rb +23 -0
- data/lib/activejob/temporal/payload_serializers/marshal.rb +53 -0
- data/lib/activejob/temporal/payload_serializers/message_pack.rb +59 -0
- data/lib/activejob/temporal/payload_serializers.rb +37 -0
- data/lib/activejob/temporal/payload_storage.rb +103 -0
- data/lib/activejob/temporal/rails_environment_loader.rb +143 -0
- data/lib/activejob/temporal/rate_limit_options.rb +94 -0
- data/lib/activejob/temporal/rate_limiters/memory.rb +198 -0
- data/lib/activejob/temporal/reload_signal_queue.rb +40 -0
- data/lib/activejob/temporal/retry_handler_extractor.rb +361 -0
- data/lib/activejob/temporal/retry_mapper.rb +264 -0
- data/lib/activejob/temporal/schedulable.rb +60 -0
- data/lib/activejob/temporal/schedule.rb +181 -0
- data/lib/activejob/temporal/schedule_options.rb +105 -0
- data/lib/activejob/temporal/search_attributes.rb +173 -0
- data/lib/activejob/temporal/signal_query.rb +161 -0
- data/lib/activejob/temporal/signal_query_options.rb +106 -0
- data/lib/activejob/temporal/temporal_options.rb +114 -0
- data/lib/activejob/temporal/tls_file.rb +45 -0
- data/lib/activejob/temporal/transaction_safety.rb +39 -0
- data/lib/activejob/temporal/version.rb +7 -0
- data/lib/activejob/temporal/visibility_query.rb +13 -0
- data/lib/activejob/temporal/worker_client_reloader.rb +34 -0
- data/lib/activejob/temporal/worker_health.rb +117 -0
- data/lib/activejob/temporal/worker_pool.rb +408 -0
- data/lib/activejob/temporal/workflow_enqueuer.rb +271 -0
- data/lib/activejob/temporal/workflow_enqueuer_batch.rb +17 -0
- data/lib/activejob/temporal/workflow_id_builder.rb +155 -0
- data/lib/activejob/temporal/workflow_identity.rb +62 -0
- data/lib/activejob/temporal/workflows/aj_workflow.rb +282 -0
- data/lib/activejob/temporal/workflows/dead_letter_support.rb +134 -0
- data/lib/activejob/temporal/workflows/dead_letter_workflow.rb +114 -0
- data/lib/activejob/temporal/workflows/workflow_chaining.rb +194 -0
- data/lib/activejob/temporal/workflows/workflow_child_workflows.rb +140 -0
- data/lib/activejob/temporal/workflows/workflow_continue_as_new.rb +44 -0
- data/lib/activejob/temporal/workflows/workflow_dependencies.rb +115 -0
- data/lib/activejob/temporal/workflows/workflow_execution_steps.rb +22 -0
- data/lib/activejob/temporal/workflows/workflow_interactions.rb +215 -0
- data/lib/activejob/temporal/workflows/workflow_local_activities.rb +29 -0
- data/lib/activejob/temporal/workflows/workflow_nexus.rb +15 -0
- data/lib/activejob/temporal/workflows/workflow_versioning.rb +21 -0
- data/lib/activejob/temporal.rb +297 -0
- data/lib/activejob-temporal.rb +3 -0
- 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
|