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,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
|