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,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveJob
4
+ module Temporal
5
+ class ActiveJobHandlerSource
6
+ def self.match?(handler, method_name)
7
+ new(handler, method_name).match?
8
+ end
9
+
10
+ def self.match_status(handler, method_name)
11
+ new(handler, method_name).match_status
12
+ end
13
+
14
+ def self.supported?(method_name)
15
+ new(nil, method_name).supported?
16
+ end
17
+
18
+ def initialize(handler, method_name)
19
+ @handler = handler
20
+ @method_name = method_name
21
+ end
22
+
23
+ def supported?
24
+ method_file, = active_job_method_source_location
25
+ method_file && File.file?(method_file)
26
+ rescue StandardError
27
+ false
28
+ end
29
+
30
+ def match?
31
+ match_status == :match
32
+ end
33
+
34
+ def match_status
35
+ return :unsupported unless handler.respond_to?(:source_location)
36
+
37
+ source_file, source_line = handler.source_location
38
+ return :unsupported unless source_file && source_line
39
+
40
+ method_file, = active_job_method_source_location
41
+ return :unsupported unless method_file
42
+ return :no_match unless same_file?(source_file, method_file)
43
+
44
+ source_name = source_method_name(source_file, source_line)
45
+ return :unsupported unless source_name
46
+
47
+ source_name == method_name.to_s ? :match : :no_match
48
+ rescue StandardError
49
+ :unsupported
50
+ end
51
+
52
+ private
53
+
54
+ attr_reader :handler, :method_name
55
+
56
+ def active_job_method_source_location
57
+ return nil unless defined?(ActiveJob::Exceptions::ClassMethods)
58
+
59
+ ActiveJob::Exceptions::ClassMethods.instance_method(method_name).source_location
60
+ rescue NameError
61
+ nil
62
+ end
63
+
64
+ def same_file?(left, right)
65
+ File.expand_path(left) == File.expand_path(right)
66
+ end
67
+
68
+ def source_method_name(source_file, source_line)
69
+ return nil unless File.file?(source_file)
70
+
71
+ lines = File.readlines(source_file)
72
+ (source_line.to_i - 1).downto(0) do |line_index|
73
+ line = lines[line_index]
74
+ next unless line
75
+
76
+ match = line.match(/^\s*def\s+([a-zA-Z_]\w*[!?=]?)/)
77
+ return match[1] if match
78
+ end
79
+
80
+ nil
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,454 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/string/inflections"
4
+ require "active_job"
5
+ require "time"
6
+ require "temporalio/activity"
7
+
8
+ require_relative "best_effort_side_effects"
9
+ require_relative "../payload"
10
+ require_relative "../retry_mapper"
11
+
12
+ module ActiveJob
13
+ module Temporal
14
+ module Activities
15
+ # Temporal activity that executes the actual ActiveJob logic.
16
+ #
17
+ # This activity hydrates the job class, deserializes arguments, and invokes
18
+ # the job's `perform` method. It is the only place where side effects occur
19
+ # (database writes, API calls, etc.).
20
+ #
21
+ # @note Idempotency and Retries
22
+ # Activities may be re-executed on transient failures due to Temporal's retry logic.
23
+ # Job implementations MUST be idempotent. This activity sets an execution-local
24
+ # idempotency key (`Fiber[:aj_temporal_idempotency_key]`) derived from the
25
+ # workflow ID to assist with idempotent external operations (e.g., API requests).
26
+ #
27
+ # @note Execution-Local Idempotency Key
28
+ # Jobs can access `Fiber[:aj_temporal_idempotency_key]` to generate unique
29
+ # idempotency tokens for external API calls. `Thread.current[...]` remains
30
+ # populated for existing synchronous jobs. The key format is "workflow_id/runner"
31
+ # and persists across retries for the same workflow execution.
32
+ #
33
+ # @note Exception Handling
34
+ # If the job raises an exception that matches a `discard_on` declaration,
35
+ # the activity raises a non-retryable `ApplicationError` to stop retries.
36
+ # Otherwise, the exception propagates and Temporal applies the retry policy.
37
+ #
38
+ # @example Activity execution flow
39
+ # 1. Deserialize job arguments from payload
40
+ # 2. Constantize job class
41
+ # 3. Set execution-local idempotency key
42
+ # 4. Instantiate job and call `perform(*args)`
43
+ # 5. Handle exceptions (discard vs. retry)
44
+ # 6. Clear idempotency key
45
+ #
46
+ # @example Using idempotency key in a job
47
+ # class ChargeCustomerJob < ApplicationJob
48
+ # def perform(customer_id, amount)
49
+ # idempotency_key = Fiber[:aj_temporal_idempotency_key]
50
+ # StripeAPI.charge(
51
+ # customer_id: customer_id,
52
+ # amount: amount,
53
+ # idempotency_key: idempotency_key
54
+ # )
55
+ # end
56
+ # end
57
+ #
58
+ # @see https://docs.temporal.io/activities Temporal Activities Guide
59
+ # @see https://docs.temporal.io/retry-policies Temporal Retry Policies
60
+ # rubocop:disable Metrics/ClassLength
61
+ class AjRunnerActivity < Temporalio::Activity::Definition
62
+ IDEMPOTENCY_KEY = :aj_temporal_idempotency_key
63
+ DESERIALIZATION_ERROR_CLASSES = [
64
+ ActiveJob::SerializationError,
65
+ ActiveJob::DeserializationError
66
+ ].freeze
67
+
68
+ class RetryRequested < StandardError
69
+ attr_reader :job, :options, :original_error
70
+
71
+ def initialize(job, options)
72
+ @job = job
73
+ @options = options
74
+ @original_error = options[:error] || options["error"]
75
+ super(@original_error&.message || "ActiveJob retry requested")
76
+ end
77
+ end
78
+
79
+ # Executes the job inside the Temporal activity context.
80
+ #
81
+ # @param payload [Hash] Job payload with serialized arguments and metadata
82
+ # @option payload [String] :job_class Fully-qualified job class name (required)
83
+ # @option payload [String] :job_id Unique job identifier
84
+ # @option payload [Array] :arguments Serialized job arguments (via ActiveJob::Arguments)
85
+ # @option payload [String] :queue_name Target queue name
86
+ # @option payload [Integer] :executions Current execution count
87
+ # @option payload [Hash] :exception_executions Exception execution counts
88
+ #
89
+ # @return [Object, nil] Result of the job's `perform` method (typically nil)
90
+ #
91
+ # @raise [ArgumentError] if payload is missing job_class
92
+ # @raise [NameError] if job_class cannot be constantized
93
+ # @raise [ActiveJob::SerializationError] if arguments cannot be deserialized
94
+ # @raise [Temporalio::Error::ApplicationError] if job raises a discardable exception (non-retryable)
95
+ # @raise [StandardError] if job raises a retryable exception (propagates to Temporal)
96
+ #
97
+ # @example Basic execution
98
+ # execute({
99
+ # job_class: "MyJob",
100
+ # job_id: "123",
101
+ # arguments: [{ "_aj_serialized" => "ActiveJob::Serializers::ObjectSerializer", "value" => {...} }]
102
+ # })
103
+ #
104
+ # @example Accessing idempotency key in job
105
+ # class MyJob < ApplicationJob
106
+ # def perform(user_id)
107
+ # key = Fiber[:aj_temporal_idempotency_key]
108
+ # ExternalAPI.create_user(user_id, idempotency_key: key)
109
+ # end
110
+ # end
111
+ #
112
+ # @example Handling discard_on exceptions
113
+ # class MyJob < ApplicationJob
114
+ # discard_on ActiveRecord::RecordNotFound
115
+ # def perform(user_id)
116
+ # User.find(user_id).do_something
117
+ # end
118
+ # end
119
+ # # If RecordNotFound is raised, activity raises non-retryable ApplicationError
120
+ def execute(payload, raw_arguments = nil)
121
+ job_class = nil
122
+ audit_context = nil
123
+ deserialized_payload = Payload.deserialize_payload(
124
+ payload,
125
+ encryption_context: activity_encryption_context(payload)
126
+ )
127
+ apply_schedule_execution_identity(deserialized_payload)
128
+ audit_context = audit_started(deserialized_payload)
129
+
130
+ side_effects = BestEffortSideEffects.new(audit_context)
131
+ result = perform_with_best_effort_observability(deserialized_payload, side_effects) do
132
+ perform_deserialized_job(deserialized_payload, raw_arguments) do |resolved_job_class|
133
+ job_class = resolved_job_class
134
+ end
135
+ end
136
+ record_success_side_effects(payload, audit_context, side_effects)
137
+ result
138
+ rescue StandardError => e
139
+ handle_activity_error(e, job_class, audit_context, deserialized_payload || payload)
140
+ ensure
141
+ clear_idempotency_key
142
+ end
143
+
144
+ private
145
+
146
+ # Constantizes job class from payload string.
147
+ # @api private
148
+ def constantize_job_class(payload)
149
+ job_class_name = payload[:job_class] || payload["job_class"]
150
+ raise ArgumentError, "payload missing job_class" unless job_class_name
151
+
152
+ job_class_name.constantize
153
+ end
154
+
155
+ def perform_job(job)
156
+ ActiveJob::Temporal.config.middleware_chain.call(job) do
157
+ job.perform_now
158
+ end
159
+ end
160
+
161
+ def perform_deserialized_job(payload, raw_arguments)
162
+ job_data = active_job_data_for(payload, raw_arguments)
163
+ job_class = constantize_job_class(job_data)
164
+ yield job_class
165
+
166
+ apply_activity_retry_state(job_data, job_class)
167
+ job = deserialize_job(job_data)
168
+ intercept_active_job_retry(job)
169
+
170
+ set_idempotency_key
171
+ perform_job(job)
172
+ end
173
+
174
+ def active_job_data_for(payload, raw_arguments)
175
+ job_data = stringify_keys(payload[:active_job] || payload["active_job"] || legacy_active_job_data(payload))
176
+ job_data["arguments"] = ActiveJob::Arguments.serialize(Array(raw_arguments)) unless raw_arguments.nil?
177
+ job_data
178
+ end
179
+
180
+ def apply_schedule_execution_identity(payload)
181
+ return unless payload_value(payload, :schedule_id)
182
+
183
+ execution_job_id = payload_value(payload, :schedule_execution_job_id) || activity_workflow_id
184
+ return unless execution_job_id
185
+
186
+ payload[:job_id] = execution_job_id
187
+ active_job_payload = payload[:active_job] || payload["active_job"]
188
+ return unless active_job_payload.is_a?(Hash)
189
+
190
+ active_job_payload["job_id"] = execution_job_id
191
+ active_job_payload["provider_job_id"] = execution_job_id
192
+ end
193
+
194
+ def legacy_active_job_data(payload)
195
+ {
196
+ "job_class" => payload_value(payload, :job_class),
197
+ "job_id" => payload_value(payload, :job_id),
198
+ "provider_job_id" => payload_value(payload, :provider_job_id),
199
+ "queue_name" => payload_value(payload, :queue_name),
200
+ "priority" => payload_value(payload, :priority),
201
+ "arguments" => payload_value(payload, :arguments) || [],
202
+ "executions" => payload_value(payload, :executions) || 0,
203
+ "exception_executions" => payload_value(payload, :exception_executions) || {},
204
+ "locale" => payload_value(payload, :locale) || default_locale,
205
+ "timezone" => payload_value(payload, :timezone) || default_timezone,
206
+ "enqueued_at" => payload_value(payload, :enqueued_at) || Time.now.utc.iso8601,
207
+ "scheduled_at" => payload_value(payload, :scheduled_at)
208
+ }.compact
209
+ end
210
+
211
+ def deserialize_job(job_data)
212
+ ActiveJob::Base.deserialize(job_data)
213
+ end
214
+
215
+ def intercept_active_job_retry(job)
216
+ job.define_singleton_method(:retry_job) do |options = {}|
217
+ raise RetryRequested.new(self, options)
218
+ end
219
+ end
220
+
221
+ def apply_activity_retry_state(job_data, job_class)
222
+ previous_attempts = activity_attempt - 1
223
+ return if previous_attempts <= 0
224
+
225
+ job_data["executions"] = [integer_or_zero(job_data["executions"]), previous_attempts].max
226
+ exception_executions = stringify_keys(job_data["exception_executions"] || {})
227
+ RetryMapper.exception_execution_keys(job_class).each do |key|
228
+ exception_executions[key] = [integer_or_zero(exception_executions[key]), previous_attempts].max
229
+ end
230
+ job_data["exception_executions"] = exception_executions
231
+ end
232
+
233
+ def perform_with_best_effort_observability(payload, side_effects)
234
+ performed = false
235
+ result = nil
236
+
237
+ instrument_perform(payload) do
238
+ result = yield
239
+ performed = true
240
+ result
241
+ end
242
+
243
+ result
244
+ rescue StandardError => e
245
+ raise unless performed
246
+
247
+ side_effects.report_after_success("observability", e)
248
+ result
249
+ end
250
+
251
+ def handle_activity_error(error, job_class, audit_context, retry_payload)
252
+ failure_context = audit_context || empty_audit_context
253
+ side_effects = BestEffortSideEffects.new(failure_context)
254
+ observed_error = observed_error_for(error)
255
+ side_effects.after_failure("audit") { audit_failed(failure_context, observed_error) }
256
+ side_effects.after_failure("retry_observability") do
257
+ record_retry_observability(retry_payload, observed_error)
258
+ end
259
+ handle_exception(job_class, error)
260
+ end
261
+
262
+ def instrument_perform(payload, &)
263
+ Observability.instrument(:perform, Observability.attributes_from_payload(payload), &)
264
+ end
265
+
266
+ def record_retry_observability(payload, error)
267
+ return unless Observability.retry_attempt?
268
+
269
+ Observability.emit(
270
+ :retry,
271
+ Observability.attributes_from_payload(payload, error: error.class.name)
272
+ )
273
+ end
274
+
275
+ def audit_started(payload)
276
+ attributes = AuditLog.activity_attributes_from_payload(payload)
277
+ AuditLog.record("job.started", attributes)
278
+
279
+ { started_at: AuditLog.monotonic_time, attributes: attributes }
280
+ end
281
+
282
+ def audit_completed(audit_context)
283
+ AuditLog.record(
284
+ "job.completed",
285
+ audit_context[:attributes].merge(duration_ms: audit_duration(audit_context))
286
+ )
287
+ end
288
+
289
+ def record_success_side_effects(payload, audit_context, side_effects)
290
+ side_effects.after_success("audit") { audit_completed(audit_context) }
291
+ side_effects.after_success("external_payload_cleanup") do
292
+ Payload.delete_external_payload(payload)
293
+ end
294
+ end
295
+
296
+ def audit_failed(audit_context, error)
297
+ cancelled = AuditLog.cancelled_error?(error)
298
+ AuditLog.record(
299
+ cancelled ? "job.cancelled" : "job.failed",
300
+ audit_context[:attributes]
301
+ .merge(duration_ms: audit_duration(audit_context))
302
+ .merge(status: ("observed" if cancelled))
303
+ .merge(AuditLog.error_attributes(error))
304
+ )
305
+ end
306
+
307
+ def empty_audit_context
308
+ { started_at: AuditLog.monotonic_time, attributes: {} }
309
+ end
310
+
311
+ def audit_duration(audit_context)
312
+ AuditLog.elapsed_milliseconds(audit_context[:started_at])
313
+ end
314
+
315
+ # @api private
316
+ def set_idempotency_key
317
+ workflow_id = if defined?(Temporalio::Activity::Context) && Temporalio::Activity::Context.exist?
318
+ Temporalio::Activity::Context.current.info.workflow_id
319
+ else
320
+ "unknown-workflow"
321
+ end
322
+ idempotency_key = "#{workflow_id}/runner"
323
+ Thread.current[IDEMPOTENCY_KEY] = idempotency_key
324
+ Fiber[IDEMPOTENCY_KEY] = idempotency_key
325
+ end
326
+
327
+ def clear_idempotency_key
328
+ Thread.current[IDEMPOTENCY_KEY] = nil
329
+ Fiber[IDEMPOTENCY_KEY] = nil
330
+ end
331
+
332
+ def activity_encryption_context(payload = nil)
333
+ payload_context = payload && (payload[:payload_encryption_context] || payload["payload_encryption_context"])
334
+ return payload_context if payload_context
335
+
336
+ return unless defined?(Temporalio::Activity::Context) && Temporalio::Activity::Context.exist?
337
+
338
+ info = Temporalio::Activity::Context.current.info
339
+ namespace = if info.respond_to?(:workflow_namespace)
340
+ info.workflow_namespace
341
+ else
342
+ ActiveJob::Temporal.config.namespace
343
+ end
344
+ { namespace: namespace, workflow_id: activity_workflow_id }
345
+ end
346
+
347
+ def activity_workflow_id
348
+ return unless defined?(Temporalio::Activity::Context) && Temporalio::Activity::Context.exist?
349
+
350
+ Temporalio::Activity::Context.current.info.workflow_id
351
+ end
352
+
353
+ # Handles exceptions by checking discard_on declarations.
354
+ # @api private
355
+ def handle_exception(job_class, error)
356
+ raise retryable_application_error(error) if error.is_a?(RetryRequested)
357
+
358
+ raise non_retryable_application_error(error) if job_class.nil? && deserialization_error?(error)
359
+
360
+ raise non_retryable_application_error(error) if job_class && retry_attempts_exhausted?(job_class, error)
361
+
362
+ raise non_retryable_application_error(error) if job_class && RetryMapper.discard_exception?(job_class, error)
363
+
364
+ raise error
365
+ end
366
+
367
+ def observed_error_for(error)
368
+ return error.original_error || error if error.is_a?(RetryRequested)
369
+
370
+ error
371
+ end
372
+
373
+ def deserialization_error?(error)
374
+ DESERIALIZATION_ERROR_CLASSES.any? { |error_class| error.is_a?(error_class) }
375
+ end
376
+
377
+ def retry_attempts_exhausted?(job_class, error)
378
+ return false unless RetryMapper.retry_handler(job_class, error)
379
+
380
+ maximum_attempts = RetryMapper.for(job_class, error)[:maximum_attempts]
381
+ maximum_attempts.positive? && activity_attempt >= maximum_attempts
382
+ end
383
+
384
+ def retryable_application_error(retry_request)
385
+ error = retry_request.original_error || retry_request
386
+ options = { type: error.class.name }
387
+ delay = retry_delay_seconds(retry_request.options[:wait] || retry_request.options["wait"])
388
+ options[:next_retry_delay] = delay if delay
389
+
390
+ Temporalio::Error::ApplicationError.new(error.message, **options)
391
+ end
392
+
393
+ def non_retryable_application_error(error)
394
+ Temporalio::Error::ApplicationError.new(
395
+ error.message,
396
+ type: error.class.name,
397
+ non_retryable: true
398
+ )
399
+ end
400
+
401
+ def retry_delay_seconds(value)
402
+ return unless value
403
+
404
+ Float(value)
405
+ rescue ArgumentError, TypeError
406
+ nil
407
+ end
408
+
409
+ def activity_attempt
410
+ return 1 unless defined?(Temporalio::Activity::Context) && Temporalio::Activity::Context.exist?
411
+
412
+ info = Temporalio::Activity::Context.current.info
413
+ return 1 unless info.respond_to?(:attempt)
414
+
415
+ [Integer(info.attempt), 1].max
416
+ rescue StandardError
417
+ 1
418
+ end
419
+
420
+ def integer_or_zero(value)
421
+ Integer(value || 0)
422
+ rescue ArgumentError, TypeError
423
+ 0
424
+ end
425
+
426
+ def payload_value(payload, key)
427
+ payload[key] || payload[key.to_s]
428
+ end
429
+
430
+ def stringify_keys(value)
431
+ case value
432
+ when Hash
433
+ value.each_with_object({}) do |(key, child_value), normalized|
434
+ normalized[key.to_s] = stringify_keys(child_value)
435
+ end
436
+ when Array
437
+ value.map { |child_value| stringify_keys(child_value) }
438
+ else
439
+ value
440
+ end
441
+ end
442
+
443
+ def default_locale
444
+ I18n.locale.to_s if defined?(I18n)
445
+ end
446
+
447
+ def default_timezone
448
+ Time.zone&.name if Time.respond_to?(:zone)
449
+ end
450
+ end
451
+ # rubocop:enable Metrics/ClassLength
452
+ end
453
+ end
454
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../audit_log"
4
+ require_relative "../logger"
5
+
6
+ module ActiveJob
7
+ module Temporal
8
+ module Activities
9
+ class BestEffortSideEffects
10
+ def initialize(audit_context)
11
+ @audit_context = audit_context
12
+ end
13
+
14
+ def after_success(side_effect)
15
+ yield
16
+ rescue StandardError => e
17
+ report_after_success(side_effect, e)
18
+ end
19
+
20
+ def after_failure(side_effect)
21
+ yield
22
+ rescue StandardError => e
23
+ report_after_failure(side_effect, e)
24
+ end
25
+
26
+ def report_after_success(side_effect, error)
27
+ report("activity_post_perform_side_effect_failed", side_effect, error)
28
+ end
29
+
30
+ def report_after_failure(side_effect, error)
31
+ report("activity_failure_side_effect_failed", side_effect, error)
32
+ end
33
+
34
+ private
35
+
36
+ def report(event_name, side_effect, error)
37
+ ActiveJob::Temporal::Logger.warn(
38
+ event_name,
39
+ @audit_context.fetch(:attributes, {})
40
+ .merge(side_effect: side_effect)
41
+ .merge(ActiveJob::Temporal::AuditLog.error_attributes(error))
42
+ )
43
+ rescue StandardError
44
+ nil
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end