activejob-temporal 0.1.0 → 0.2.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 +4 -4
- data/AGENTS.md +1 -0
- data/CHANGELOG.md +18 -0
- data/CLAUDE.md +274 -0
- data/CONTRIBUTING.md +69 -0
- data/README.md +34 -33
- data/activejob-temporal.gemspec +8 -12
- data/api/job_payload_schema.json +51 -6
- data/bin/temporal-worker +3 -8
- data/gemfiles/activejob_contract.gemfile +8 -0
- data/gemfiles/temporalio_contract.gemfile +7 -0
- data/lib/activejob/temporal/activities/aj_runner_activity.rb +75 -19
- data/lib/activejob/temporal/activities/dependency_status_activity.rb +36 -19
- data/lib/activejob/temporal/adapter.rb +4 -3
- data/lib/activejob/temporal/batch_enqueuer.rb +42 -33
- data/lib/activejob/temporal/bind_policy.rb +1 -1
- data/lib/activejob/temporal/cancel.rb +66 -29
- data/lib/activejob/temporal/certificate_watcher.rb +41 -6
- data/lib/activejob/temporal/client.rb +4 -3
- data/lib/activejob/temporal/conditional_enqueue.rb +2 -1
- data/lib/activejob/temporal/configurable.rb +56 -13
- data/lib/activejob/temporal/configuration.rb +139 -7
- data/lib/activejob/temporal/configured_job_compatibility.rb +91 -9
- data/lib/activejob/temporal/connection_worker_pool.rb +18 -1
- data/lib/activejob/temporal/dead_letter_queue.rb +64 -18
- data/lib/activejob/temporal/dependency_options.rb +126 -16
- data/lib/activejob/temporal/health_check_server.rb +14 -17
- data/lib/activejob/temporal/http_line_reader.rb +20 -2
- data/lib/activejob/temporal/http_request_failure_handling.rb +41 -0
- data/lib/activejob/temporal/inspect.rb +17 -7
- data/lib/activejob/temporal/job_id_validation.rb +41 -0
- data/lib/activejob/temporal/job_payload_dependencies.rb +23 -0
- data/lib/activejob/temporal/locales/en.yml +15 -3
- data/lib/activejob/temporal/metrics_server.rb +15 -18
- data/lib/activejob/temporal/middleware/chain.rb +7 -0
- data/lib/activejob/temporal/observability.rb +14 -2
- data/lib/activejob/temporal/payload.rb +64 -25
- data/lib/activejob/temporal/payload_encryption.rb +9 -1
- data/lib/activejob/temporal/payload_serializers.rb +3 -0
- data/lib/activejob/temporal/payload_storage.rb +4 -4
- data/lib/activejob/temporal/rails_environment_loader.rb +1 -8
- data/lib/activejob/temporal/reload_signal_queue.rb +19 -19
- data/lib/activejob/temporal/retry_handler_extractor.rb +18 -3
- data/lib/activejob/temporal/schedulable.rb +5 -7
- data/lib/activejob/temporal/schedule.rb +2 -2
- data/lib/activejob/temporal/signal_query.rb +35 -23
- data/lib/activejob/temporal/temporal_options.rb +26 -1
- data/lib/activejob/temporal/tls_file.rb +16 -16
- data/lib/activejob/temporal/transaction_safety.rb +102 -1
- data/lib/activejob/temporal/version.rb +1 -1
- data/lib/activejob/temporal/visibility_query.rb +16 -1
- data/lib/activejob/temporal/worker_pool.rb +13 -0
- data/lib/activejob/temporal/worker_runtime.rb +16 -0
- data/lib/activejob/temporal/workflow_enqueuer.rb +2 -1
- data/lib/activejob/temporal/workflow_types.rb +10 -0
- data/lib/activejob/temporal/workflows/aj_workflow.rb +17 -4
- data/lib/activejob/temporal/workflows/workflow_dependencies.rb +148 -15
- data/lib/activejob/temporal.rb +2 -13
- data/test/mutant_unit_test.rb +13 -0
- metadata +43 -48
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "temporalio/error"
|
|
4
|
+
require_relative "job_id_validation"
|
|
3
5
|
require_relative "visibility_query"
|
|
4
6
|
require_relative "workflow_id_builder"
|
|
5
7
|
|
|
@@ -27,6 +29,48 @@ module ActiveJob
|
|
|
27
29
|
# @see https://docs.temporal.io/activities#heartbeat Temporal Activity Heartbeating
|
|
28
30
|
# @see https://docs.temporal.io/workflows#cancellation Temporal Cancellation Guide
|
|
29
31
|
module Cancel
|
|
32
|
+
class CancellationRequest
|
|
33
|
+
def initialize(client:, job_id:, workflow_id:, run_id: nil)
|
|
34
|
+
@client = client
|
|
35
|
+
@job_id = job_id
|
|
36
|
+
@workflow_id = workflow_id
|
|
37
|
+
@run_id = run_id
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def call
|
|
41
|
+
cancellation_handle.cancel
|
|
42
|
+
rescue StandardError => e
|
|
43
|
+
raise workflow_not_found_error, cause: e if rpc_not_found?(e)
|
|
44
|
+
|
|
45
|
+
raise ActiveJob::Temporal::TemporalConnectionError.new(
|
|
46
|
+
"Failed to cancel Temporal workflow for job_id #{@job_id}: #{e.message}"
|
|
47
|
+
), cause: e
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
attr_reader :client, :workflow_id, :run_id
|
|
53
|
+
|
|
54
|
+
def cancellation_handle
|
|
55
|
+
return client.workflow_handle(workflow_id) unless run_id
|
|
56
|
+
|
|
57
|
+
client.workflow_handle(workflow_id, run_id: run_id)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def workflow_not_found_error
|
|
61
|
+
ActiveJob::Temporal::WorkflowNotFoundError.new(
|
|
62
|
+
"No workflow found for job_id #{@job_id}. The job may have completed or never existed."
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def rpc_not_found?(error)
|
|
67
|
+
defined?(Temporalio::Error::RPCError) &&
|
|
68
|
+
error.is_a?(Temporalio::Error::RPCError) &&
|
|
69
|
+
error.code == Temporalio::Error::RPCError::Code::NOT_FOUND
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
private_constant :CancellationRequest
|
|
73
|
+
|
|
30
74
|
class << self
|
|
31
75
|
# Cancels a running Temporal workflow by sending a cancellation request.
|
|
32
76
|
#
|
|
@@ -38,7 +82,7 @@ module ActiveJob
|
|
|
38
82
|
# @return [Boolean, nil] Returns false if workflow already completed, nil if cancellation requested
|
|
39
83
|
#
|
|
40
84
|
# @raise [WorkflowNotFoundError] if no workflow exists for the given job_id
|
|
41
|
-
# @raise [TemporalConnectionError] if
|
|
85
|
+
# @raise [TemporalConnectionError] if Temporal lookup or cancellation RPCs fail
|
|
42
86
|
#
|
|
43
87
|
# @note Asynchronous Cancellation
|
|
44
88
|
# Cancellation requests are asynchronous. The method returns immediately after
|
|
@@ -67,6 +111,8 @@ module ActiveJob
|
|
|
67
111
|
def cancel(job_class, job_id)
|
|
68
112
|
validate_job_id!(job_id)
|
|
69
113
|
client = ActiveJob::Temporal.client
|
|
114
|
+
return nil if cancel_schedule_execution(client, job_class, job_id)
|
|
115
|
+
|
|
70
116
|
workflow_state = find_workflow(client, job_class, job_id)
|
|
71
117
|
workflow_id = workflow_state[:workflow_id]
|
|
72
118
|
|
|
@@ -78,7 +124,7 @@ module ActiveJob
|
|
|
78
124
|
raise ActiveJob::Temporal::WorkflowNotFoundError,
|
|
79
125
|
"No workflow found for job_id #{job_id}. The job may have never existed."
|
|
80
126
|
when :running
|
|
81
|
-
client
|
|
127
|
+
request_cancellation(client, job_id, workflow_id)
|
|
82
128
|
log_cancellation_requested(job_class, job_id, workflow_id)
|
|
83
129
|
log_audit_cancellation_requested(job_class, job_id, workflow_id)
|
|
84
130
|
nil
|
|
@@ -95,11 +141,6 @@ module ActiveJob
|
|
|
95
141
|
BatchCanceller.new(ActiveJob::Temporal.client).cancel_where(filters)
|
|
96
142
|
end
|
|
97
143
|
|
|
98
|
-
# UUID format regex (compliant with RFC 4122).
|
|
99
|
-
# Matches standard UUID format: 8-4-4-4-12 hexadecimal characters.
|
|
100
|
-
# @api private
|
|
101
|
-
UUID_REGEX = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
|
|
102
|
-
|
|
103
144
|
private
|
|
104
145
|
|
|
105
146
|
def validate_job_class!(job_class)
|
|
@@ -108,22 +149,28 @@ module ActiveJob
|
|
|
108
149
|
raise ArgumentError, "job_class must be a named class"
|
|
109
150
|
end
|
|
110
151
|
|
|
111
|
-
# Validates that job_id is a valid UUID format.
|
|
112
|
-
#
|
|
113
|
-
# ActiveJob generates job IDs using SecureRandom.uuid, which produces RFC 4122
|
|
114
|
-
# compliant UUIDs. This validation prevents search query injection attacks by
|
|
115
|
-
# ensuring job_id contains only hexadecimal characters and hyphens, making it
|
|
116
|
-
# safe for direct use in Temporal queries.
|
|
117
|
-
#
|
|
118
152
|
# @param job_id [String] The job identifier to validate
|
|
119
|
-
# @raise [ArgumentError] if job_id is not a
|
|
153
|
+
# @raise [ArgumentError] if job_id is not a safe string identifier
|
|
120
154
|
# @api private
|
|
121
155
|
def validate_job_id!(job_id)
|
|
122
|
-
|
|
156
|
+
JobIdValidation.validate!(job_id)
|
|
157
|
+
end
|
|
123
158
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
159
|
+
def cancel_schedule_execution(client, job_class, job_id)
|
|
160
|
+
schedule_reference = JobIdValidation.schedule_execution_reference(job_id)
|
|
161
|
+
return false unless schedule_reference
|
|
162
|
+
|
|
163
|
+
workflow_id = schedule_reference.fetch(:workflow_id)
|
|
164
|
+
request_cancellation(client, job_id, workflow_id, run_id: schedule_reference.fetch(:run_id))
|
|
165
|
+
log_cancellation_requested(job_class, job_id, workflow_id)
|
|
166
|
+
log_audit_cancellation_requested(job_class, job_id, workflow_id)
|
|
167
|
+
true
|
|
168
|
+
rescue ActiveJob::Temporal::WorkflowNotFoundError
|
|
169
|
+
false
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def request_cancellation(client, job_id, workflow_id, run_id: nil)
|
|
173
|
+
CancellationRequest.new(client: client, job_id: job_id, workflow_id: workflow_id, run_id: run_id).call
|
|
127
174
|
end
|
|
128
175
|
|
|
129
176
|
# Builds deterministic workflow ID from job class and job ID.
|
|
@@ -167,21 +214,11 @@ module ActiveJob
|
|
|
167
214
|
{ status: status, workflow_id: workflow_id }
|
|
168
215
|
end
|
|
169
216
|
|
|
170
|
-
# Builds Temporal query for running workflows by job class and job_id.
|
|
171
|
-
#
|
|
172
|
-
# Note: job_id is validated as a UUID before reaching this method, ensuring it
|
|
173
|
-
# contains only safe characters ([0-9a-fA-F-]) for direct query interpolation.
|
|
174
|
-
#
|
|
175
217
|
# @api private
|
|
176
218
|
def running_workflows_query(job_class, job_id)
|
|
177
219
|
workflow_search_query(job_class, job_id, "ExecutionStatus='Running'")
|
|
178
220
|
end
|
|
179
221
|
|
|
180
|
-
# Builds Temporal query for closed workflows by job class and job_id.
|
|
181
|
-
#
|
|
182
|
-
# Note: job_id is validated as a UUID before reaching this method, ensuring it
|
|
183
|
-
# contains only safe characters ([0-9a-fA-F-]) for direct query interpolation.
|
|
184
|
-
#
|
|
185
222
|
# @api private
|
|
186
223
|
def closed_workflows_query(job_class, job_id)
|
|
187
224
|
workflow_search_query(
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "listen"
|
|
4
|
-
|
|
5
3
|
module ActiveJob
|
|
6
4
|
module Temporal
|
|
7
5
|
# Watches TLS certificate files and runs a reload callback when they change.
|
|
@@ -16,7 +14,7 @@ module ActiveJob
|
|
|
16
14
|
].compact.reject { |path| path.to_s.strip.empty? }
|
|
17
15
|
end
|
|
18
16
|
|
|
19
|
-
def initialize(paths:, reload_callback:, listener_factory:
|
|
17
|
+
def initialize(paths:, reload_callback:, listener_factory: nil, debounce_seconds: DEFAULT_DEBOUNCE_SECONDS)
|
|
20
18
|
@paths = paths.map { |path| File.expand_path(path) }.uniq
|
|
21
19
|
@reload_callback = reload_callback
|
|
22
20
|
@listener_factory = listener_factory
|
|
@@ -24,12 +22,13 @@ module ActiveJob
|
|
|
24
22
|
@mutex = Mutex.new
|
|
25
23
|
@last_reload_at = nil
|
|
26
24
|
@listener = nil
|
|
25
|
+
@trailing_reload = nil
|
|
27
26
|
end
|
|
28
27
|
|
|
29
28
|
def start
|
|
30
29
|
return self if @paths.empty? || @listener
|
|
31
30
|
|
|
32
|
-
@listener =
|
|
31
|
+
@listener = listener_factory.to(*directories) do |modified, added, removed|
|
|
33
32
|
handle_changes(modified + added + removed)
|
|
34
33
|
end
|
|
35
34
|
@listener.start
|
|
@@ -39,21 +38,57 @@ module ActiveJob
|
|
|
39
38
|
def stop
|
|
40
39
|
@listener&.stop
|
|
41
40
|
@listener = nil
|
|
41
|
+
@mutex.synchronize { @trailing_reload }&.kill
|
|
42
42
|
end
|
|
43
43
|
|
|
44
44
|
def handle_changes(changed_paths)
|
|
45
45
|
return unless relevant_change?(changed_paths)
|
|
46
|
-
return if debounced?
|
|
47
46
|
|
|
48
|
-
|
|
47
|
+
if debounced?
|
|
48
|
+
schedule_trailing_reload
|
|
49
|
+
else
|
|
50
|
+
reload(retry_on_failure: true)
|
|
51
|
+
end
|
|
49
52
|
end
|
|
50
53
|
|
|
51
54
|
private
|
|
52
55
|
|
|
56
|
+
# A failed reload clears the debounce stamp so the next change reloads
|
|
57
|
+
# immediately, and gets one retry in case no further change arrives.
|
|
58
|
+
def reload(retry_on_failure:)
|
|
59
|
+
@reload_callback.call
|
|
60
|
+
rescue StandardError
|
|
61
|
+
@mutex.synchronize { @last_reload_at = nil }
|
|
62
|
+
schedule_trailing_reload if retry_on_failure
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Cert and key rotate as separate writes, so the second one lands inside
|
|
66
|
+
# the debounce window. Flush it once the window closes instead of dropping it.
|
|
67
|
+
def schedule_trailing_reload
|
|
68
|
+
@mutex.synchronize do
|
|
69
|
+
return if @trailing_reload&.alive?
|
|
70
|
+
|
|
71
|
+
@trailing_reload = Thread.new do
|
|
72
|
+
sleep(@debounce_seconds)
|
|
73
|
+
@mutex.synchronize { @last_reload_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
74
|
+
reload(retry_on_failure: false)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
53
79
|
def directories
|
|
54
80
|
@directories ||= @paths.map { |path| File.dirname(path) }.uniq
|
|
55
81
|
end
|
|
56
82
|
|
|
83
|
+
def listener_factory
|
|
84
|
+
@listener_factory ||= begin
|
|
85
|
+
require "listen"
|
|
86
|
+
Listen
|
|
87
|
+
rescue LoadError => e
|
|
88
|
+
raise LoadError, "listen gem is required when tls_cert_watch is enabled: #{e.message}"
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
57
92
|
def relevant_change?(changed_paths)
|
|
58
93
|
changed_paths.any? do |path|
|
|
59
94
|
@paths.include?(File.expand_path(path))
|
|
@@ -71,7 +71,8 @@ module ActiveJob
|
|
|
71
71
|
#
|
|
72
72
|
# @return [Temporalio::Client] Connected Temporal client
|
|
73
73
|
#
|
|
74
|
-
# @raise [ActiveJob::Temporal::
|
|
74
|
+
# @raise [ActiveJob::Temporal::TemporalConnectionError] if connection fails
|
|
75
|
+
# (includes target, namespace, and error message)
|
|
75
76
|
# @raise [OpenSSL::SSL::SSLError] if TLS certificate validation fails
|
|
76
77
|
# @raise [OpenSSL::PKey::RSAError] if TLS private key is invalid
|
|
77
78
|
# @raise [OpenSSL::X509::CertificateError] if TLS certificate is malformed
|
|
@@ -95,7 +96,7 @@ module ActiveJob
|
|
|
95
96
|
# @example Handling connection failures
|
|
96
97
|
# begin
|
|
97
98
|
# client = Client.build(config)
|
|
98
|
-
# rescue ActiveJob::Temporal::
|
|
99
|
+
# rescue ActiveJob::Temporal::TemporalConnectionError => e
|
|
99
100
|
# Rails.logger.fatal("Cannot connect to Temporal: #{e.message}")
|
|
100
101
|
# # Fall back to different adapter or alert operations team
|
|
101
102
|
# end
|
|
@@ -106,7 +107,7 @@ module ActiveJob
|
|
|
106
107
|
**connection_kwargs(configuration)
|
|
107
108
|
)
|
|
108
109
|
rescue StandardError => e
|
|
109
|
-
raise ActiveJob::Temporal::
|
|
110
|
+
raise ActiveJob::Temporal::TemporalConnectionError,
|
|
110
111
|
format(
|
|
111
112
|
"Unable to connect to Temporal at %<target>s (namespace: %<namespace>s): %<error>s",
|
|
112
113
|
target: configuration.target,
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "active_job"
|
|
4
4
|
require "active_support/concern"
|
|
5
|
+
require_relative "configured_job_compatibility"
|
|
5
6
|
|
|
6
7
|
module ActiveJob
|
|
7
8
|
module Temporal
|
|
@@ -39,7 +40,7 @@ module ActiveJob
|
|
|
39
40
|
# Adds conditional enqueue helpers to ActiveJob configured jobs.
|
|
40
41
|
module ConfiguredConditionalEnqueue
|
|
41
42
|
def perform_later_if(condition, *arguments, **keyword_arguments, &)
|
|
42
|
-
job_class =
|
|
43
|
+
job_class = ConfiguredJobCompatibility.job_class(self, feature: "conditional_enqueue")
|
|
43
44
|
condition_arguments = ConditionalEnqueue.job_arguments(arguments, keyword_arguments)
|
|
44
45
|
return nil unless ConditionalEnqueue.condition_allows_enqueue?(job_class, condition, condition_arguments)
|
|
45
46
|
|
|
@@ -19,28 +19,23 @@ module ActiveJob
|
|
|
19
19
|
end
|
|
20
20
|
alias configuration config
|
|
21
21
|
|
|
22
|
-
# Configures the gem with a block and validates
|
|
22
|
+
# Configures the gem with a block and validates before publishing changes.
|
|
23
23
|
#
|
|
24
24
|
# @yield [config] Gives the configuration object to the block
|
|
25
25
|
# @yieldparam config [Configuration] the configuration to modify
|
|
26
26
|
# @return [Configuration] the configuration object
|
|
27
27
|
# @raise [ConfigurationError] if validation fails after configuration
|
|
28
|
-
def configure
|
|
29
|
-
return config unless
|
|
28
|
+
def configure(&block)
|
|
29
|
+
return config unless block
|
|
30
30
|
|
|
31
31
|
@config_mvar ||= Concurrent::MVar.new(Configuration.new)
|
|
32
|
-
|
|
33
|
-
configuration.in_configure_block = true
|
|
32
|
+
applied_configuration = nil
|
|
34
33
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
ensure
|
|
38
|
-
configuration.in_configure_block = false
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
configuration.validate!
|
|
42
|
-
configuration
|
|
34
|
+
@config_mvar.modify do |current_configuration|
|
|
35
|
+
applied_configuration = build_configuration_candidate(current_configuration, &block)
|
|
43
36
|
end
|
|
37
|
+
|
|
38
|
+
applied_configuration
|
|
44
39
|
end
|
|
45
40
|
|
|
46
41
|
# Validates the current configuration.
|
|
@@ -50,6 +45,54 @@ module ActiveJob
|
|
|
50
45
|
def validate!
|
|
51
46
|
config.validate!
|
|
52
47
|
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def build_configuration_candidate(current_configuration)
|
|
52
|
+
candidate_configuration = current_configuration.dup
|
|
53
|
+
begin
|
|
54
|
+
candidate_configuration.in_configure_block = true
|
|
55
|
+
yield(candidate_configuration)
|
|
56
|
+
ensure
|
|
57
|
+
candidate_configuration.in_configure_block = false
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
candidate_configuration.validate!
|
|
61
|
+
deactivate_replaced_observability_adapters(current_configuration, candidate_configuration)
|
|
62
|
+
publish_configuration_candidate(current_configuration, candidate_configuration)
|
|
63
|
+
rescue StandardError
|
|
64
|
+
discard_configuration_candidate(current_configuration, candidate_configuration)
|
|
65
|
+
raise
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def publish_configuration_candidate(current_configuration, candidate_configuration)
|
|
69
|
+
current_configuration.instance_variable_set(
|
|
70
|
+
:@attributes,
|
|
71
|
+
candidate_configuration.instance_variable_get(:@attributes)
|
|
72
|
+
)
|
|
73
|
+
current_configuration.in_configure_block = false
|
|
74
|
+
current_configuration.finalize_configuration_copy!
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def deactivate_replaced_observability_adapters(previous_configuration, current_configuration)
|
|
78
|
+
previous_adapters = previous_configuration.observability.adapters
|
|
79
|
+
current_adapters = current_configuration.observability.adapters
|
|
80
|
+
|
|
81
|
+
previous_adapters.each do |adapter|
|
|
82
|
+
adapter.stop! unless current_adapters.include?(adapter)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def discard_configuration_candidate(previous_configuration, candidate_configuration)
|
|
87
|
+
previous_adapters = previous_configuration.observability.adapters
|
|
88
|
+
candidate_adapters = candidate_configuration.observability.adapters
|
|
89
|
+
|
|
90
|
+
candidate_adapters.each do |adapter|
|
|
91
|
+
adapter.stop! unless previous_adapters.include?(adapter)
|
|
92
|
+
end
|
|
93
|
+
rescue StandardError
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
53
96
|
end
|
|
54
97
|
end
|
|
55
98
|
end
|
|
@@ -38,6 +38,8 @@ module ActiveJob
|
|
|
38
38
|
TARGET_HOST_LABEL_PATTERN = /\A[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\z/
|
|
39
39
|
TARGET_PORT_PATTERN = /\A[1-9]\d{0,4}\z/
|
|
40
40
|
NAMESPACE_PATTERN = /\A[A-Za-z0-9](?:[A-Za-z0-9_-]*[A-Za-z0-9])?\z/
|
|
41
|
+
ENV_BOOLEAN_TRUE_VALUES = %w[1 true yes on].freeze
|
|
42
|
+
ENV_BOOLEAN_FALSE_VALUES = %w[0 false no off].freeze
|
|
41
43
|
|
|
42
44
|
# Central registry of all configuration attributes.
|
|
43
45
|
#
|
|
@@ -311,6 +313,13 @@ module ActiveJob
|
|
|
311
313
|
description: "Base64-encoded 32-byte AES-256-GCM payload encryption key or key metadata"
|
|
312
314
|
},
|
|
313
315
|
|
|
316
|
+
allow_legacy_encrypted_payloads: {
|
|
317
|
+
default: false,
|
|
318
|
+
env_var: "ACTIVEJOB_TEMPORAL_ALLOW_LEGACY_ENCRYPTED_PAYLOADS",
|
|
319
|
+
type: :boolean,
|
|
320
|
+
description: "Accept version 1 encrypted payloads, which are not bound to a namespace/workflow context"
|
|
321
|
+
},
|
|
322
|
+
|
|
314
323
|
encryption_old_keys: {
|
|
315
324
|
default: -> { [] },
|
|
316
325
|
type: :array,
|
|
@@ -330,6 +339,34 @@ module ActiveJob
|
|
|
330
339
|
description: "Optional workflow history event threshold for continuing ActiveJob workflows as new"
|
|
331
340
|
},
|
|
332
341
|
|
|
342
|
+
dependency_wait_timeout: {
|
|
343
|
+
default: -> { 1.day },
|
|
344
|
+
env_var: "ACTIVEJOB_TEMPORAL_DEPENDENCY_WAIT_TIMEOUT_SECONDS",
|
|
345
|
+
type: :duration,
|
|
346
|
+
description: "Maximum time a workflow waits for dependencies before timing out"
|
|
347
|
+
},
|
|
348
|
+
|
|
349
|
+
dependency_wait_initial_interval: {
|
|
350
|
+
default: -> { 10.seconds },
|
|
351
|
+
env_var: "ACTIVEJOB_TEMPORAL_DEPENDENCY_WAIT_INITIAL_INTERVAL_SECONDS",
|
|
352
|
+
type: :duration,
|
|
353
|
+
description: "Initial durable sleep interval between dependency checks"
|
|
354
|
+
},
|
|
355
|
+
|
|
356
|
+
dependency_wait_max_interval: {
|
|
357
|
+
default: -> { 1.minute },
|
|
358
|
+
env_var: "ACTIVEJOB_TEMPORAL_DEPENDENCY_WAIT_MAX_INTERVAL_SECONDS",
|
|
359
|
+
type: :duration,
|
|
360
|
+
description: "Maximum durable sleep interval between dependency checks"
|
|
361
|
+
},
|
|
362
|
+
|
|
363
|
+
dependency_wait_backoff: {
|
|
364
|
+
default: 2.0,
|
|
365
|
+
env_var: "ACTIVEJOB_TEMPORAL_DEPENDENCY_WAIT_BACKOFF",
|
|
366
|
+
type: :float,
|
|
367
|
+
description: "Backoff coefficient for dependency polling intervals"
|
|
368
|
+
},
|
|
369
|
+
|
|
333
370
|
local_activity_helpers: {
|
|
334
371
|
default: -> { [] },
|
|
335
372
|
type: :array,
|
|
@@ -357,13 +394,18 @@ module ActiveJob
|
|
|
357
394
|
# Use {ActiveJob::Temporal.configure} to set values with automatic validation.
|
|
358
395
|
#
|
|
359
396
|
# @note Thread Safety
|
|
360
|
-
# The global configuration object is synchronized by {Configurable}
|
|
361
|
-
#
|
|
397
|
+
# The global configuration object is synchronized by {Configurable}.
|
|
398
|
+
# Configure blocks mutate a candidate copy that replaces the active
|
|
399
|
+
# configuration only after validation succeeds.
|
|
362
400
|
#
|
|
363
401
|
# @note Environment Variable Defaults
|
|
364
402
|
# ACTIVEJOB_TEMPORAL_TARGET, ACTIVEJOB_TEMPORAL_NAMESPACE,
|
|
365
403
|
# ACTIVEJOB_TEMPORAL_TASK_QUEUE_PREFIX, ACTIVEJOB_TEMPORAL_TASK_QUEUE,
|
|
366
404
|
# ACTIVEJOB_TEMPORAL_MAX_PAYLOAD_SIZE_KB,
|
|
405
|
+
# ACTIVEJOB_TEMPORAL_DEPENDENCY_WAIT_TIMEOUT_SECONDS,
|
|
406
|
+
# ACTIVEJOB_TEMPORAL_DEPENDENCY_WAIT_INITIAL_INTERVAL_SECONDS,
|
|
407
|
+
# ACTIVEJOB_TEMPORAL_DEPENDENCY_WAIT_MAX_INTERVAL_SECONDS,
|
|
408
|
+
# ACTIVEJOB_TEMPORAL_DEPENDENCY_WAIT_BACKOFF,
|
|
367
409
|
# ACTIVEJOB_TEMPORAL_MAX_CONCURRENT_ACTIVITIES,
|
|
368
410
|
# ACTIVEJOB_TEMPORAL_MAX_CONCURRENT_WORKFLOW_TASKS,
|
|
369
411
|
# can provide defaults.
|
|
@@ -394,6 +436,14 @@ module ActiveJob
|
|
|
394
436
|
end
|
|
395
437
|
end
|
|
396
438
|
|
|
439
|
+
def initialize_copy(original)
|
|
440
|
+
super
|
|
441
|
+
@attributes = original.instance_variable_get(:@attributes).transform_values do |value|
|
|
442
|
+
duplicate_configuration_value(value)
|
|
443
|
+
end
|
|
444
|
+
@in_configure_block = false
|
|
445
|
+
end
|
|
446
|
+
|
|
397
447
|
def [](key)
|
|
398
448
|
@attributes[key]
|
|
399
449
|
end
|
|
@@ -406,6 +456,26 @@ module ActiveJob
|
|
|
406
456
|
middleware_chain.add(middleware, ...)
|
|
407
457
|
end
|
|
408
458
|
|
|
459
|
+
# Attributes holding secrets, redacted by {#inspect}.
|
|
460
|
+
REDACTED_ATTRIBUTES = %i[encryption_key encryption_old_keys tls].freeze
|
|
461
|
+
REDACTED_PLACEHOLDER = "[FILTERED]"
|
|
462
|
+
|
|
463
|
+
# Returns the configuration with secret attributes redacted.
|
|
464
|
+
#
|
|
465
|
+
# The default `Object#inspect` dumps `@attributes`, which exposes the payload
|
|
466
|
+
# encryption keys and the inline TLS private key in logs and exception output.
|
|
467
|
+
#
|
|
468
|
+
# @return [String] inspect output with encryption keys and TLS settings filtered
|
|
469
|
+
def inspect
|
|
470
|
+
pairs = @attributes.map do |attribute, value|
|
|
471
|
+
value = REDACTED_PLACEHOLDER if REDACTED_ATTRIBUTES.include?(attribute) && value.present?
|
|
472
|
+
|
|
473
|
+
"#{attribute}=#{value.inspect}"
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
"#<#{self.class.name} #{pairs.join(', ')}>"
|
|
477
|
+
end
|
|
478
|
+
|
|
409
479
|
def validate!
|
|
410
480
|
return if validation_level == :none
|
|
411
481
|
|
|
@@ -415,6 +485,11 @@ module ActiveJob
|
|
|
415
485
|
handle_validation_errors(validator.errors)
|
|
416
486
|
end
|
|
417
487
|
|
|
488
|
+
def finalize_configuration_copy!
|
|
489
|
+
observability.finalize_configuration_copy! if observability.respond_to?(:finalize_configuration_copy!)
|
|
490
|
+
self
|
|
491
|
+
end
|
|
492
|
+
|
|
418
493
|
def self.format_validation_errors(errors)
|
|
419
494
|
messages = errors.full_messages
|
|
420
495
|
return messages.first if messages.size == 1
|
|
@@ -429,6 +504,19 @@ module ActiveJob
|
|
|
429
504
|
|
|
430
505
|
private
|
|
431
506
|
|
|
507
|
+
def duplicate_configuration_value(value)
|
|
508
|
+
case value
|
|
509
|
+
when Hash
|
|
510
|
+
value.transform_values { |entry| duplicate_configuration_value(entry) }
|
|
511
|
+
when Array
|
|
512
|
+
value.map { |entry| duplicate_configuration_value(entry) }
|
|
513
|
+
when String, Middleware::Chain, Observability::Configuration
|
|
514
|
+
value.dup
|
|
515
|
+
else
|
|
516
|
+
value
|
|
517
|
+
end
|
|
518
|
+
end
|
|
519
|
+
|
|
432
520
|
def build_validator
|
|
433
521
|
validator = ConfigValidator.new
|
|
434
522
|
|
|
@@ -448,24 +536,37 @@ module ActiveJob
|
|
|
448
536
|
end
|
|
449
537
|
|
|
450
538
|
def resolve_default_value(metadata)
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
539
|
+
env_var = metadata[:env_var]
|
|
540
|
+
if env_var && ENV[env_var]
|
|
541
|
+
value = ENV[env_var]
|
|
542
|
+
return convert_env_value(value, metadata[:type], env_var)
|
|
454
543
|
end
|
|
455
544
|
|
|
456
545
|
default = metadata[:default]
|
|
457
546
|
default.is_a?(Proc) ? default.call : default
|
|
458
547
|
end
|
|
459
548
|
|
|
460
|
-
def convert_env_value(value, type)
|
|
549
|
+
def convert_env_value(value, type, env_var)
|
|
461
550
|
case type
|
|
462
551
|
when :integer then value.to_i
|
|
463
552
|
when :float, :duration then value.to_f
|
|
464
|
-
when :boolean then value
|
|
553
|
+
when :boolean then convert_env_boolean(value, env_var)
|
|
465
554
|
when :symbol then value.to_sym
|
|
466
555
|
else value
|
|
467
556
|
end
|
|
468
557
|
end
|
|
558
|
+
|
|
559
|
+
def convert_env_boolean(value, env_var)
|
|
560
|
+
normalized = value.to_s.strip.downcase
|
|
561
|
+
|
|
562
|
+
return true if ENV_BOOLEAN_TRUE_VALUES.include?(normalized)
|
|
563
|
+
return false if ENV_BOOLEAN_FALSE_VALUES.include?(normalized)
|
|
564
|
+
|
|
565
|
+
raise ConfigurationError,
|
|
566
|
+
"Invalid boolean value for #{env_var}: #{value.inspect}. " \
|
|
567
|
+
"Accepted true values: #{ENV_BOOLEAN_TRUE_VALUES.join(', ')}. " \
|
|
568
|
+
"Accepted false values: #{ENV_BOOLEAN_FALSE_VALUES.join(', ')}."
|
|
569
|
+
end
|
|
469
570
|
end
|
|
470
571
|
|
|
471
572
|
# Validates ActiveJob::Temporal configuration.
|
|
@@ -598,6 +699,7 @@ module ActiveJob
|
|
|
598
699
|
validate :validate_payload_storage_settings
|
|
599
700
|
validate :validate_tls_settings
|
|
600
701
|
validate :validate_local_activity_helpers
|
|
702
|
+
validate :validate_dependency_wait_settings
|
|
601
703
|
|
|
602
704
|
private
|
|
603
705
|
|
|
@@ -645,6 +747,30 @@ module ActiveJob
|
|
|
645
747
|
validate_duration_attribute(:default_schedule_to_start_timeout, required: false)
|
|
646
748
|
validate_duration_attribute(:default_schedule_to_close_timeout, required: false)
|
|
647
749
|
validate_duration_attribute(:dead_letter_auto_discard_after, required: false)
|
|
750
|
+
validate_duration_attribute(:dependency_wait_timeout, required: true)
|
|
751
|
+
validate_duration_attribute(:dependency_wait_initial_interval, required: true)
|
|
752
|
+
validate_duration_attribute(:dependency_wait_max_interval, required: true)
|
|
753
|
+
end
|
|
754
|
+
|
|
755
|
+
def validate_dependency_wait_settings
|
|
756
|
+
validate_dependency_wait_backoff
|
|
757
|
+
validate_dependency_wait_interval_order
|
|
758
|
+
end
|
|
759
|
+
|
|
760
|
+
def validate_dependency_wait_backoff
|
|
761
|
+
value = dependency_wait_backoff
|
|
762
|
+
return if value.is_a?(Numeric) && value >= 1.0
|
|
763
|
+
|
|
764
|
+
errors.add(:dependency_wait_backoff, "must be greater than or equal to 1")
|
|
765
|
+
end
|
|
766
|
+
|
|
767
|
+
def validate_dependency_wait_interval_order
|
|
768
|
+
initial_interval = duration_seconds(dependency_wait_initial_interval)
|
|
769
|
+
max_interval = duration_seconds(dependency_wait_max_interval)
|
|
770
|
+
return unless initial_interval && max_interval
|
|
771
|
+
return if max_interval >= initial_interval
|
|
772
|
+
|
|
773
|
+
errors.add(:dependency_wait_max_interval, "must be greater than or equal to initial interval")
|
|
648
774
|
end
|
|
649
775
|
|
|
650
776
|
def validate_workflow_id_generator
|
|
@@ -974,6 +1100,12 @@ module ActiveJob
|
|
|
974
1100
|
seconds: seconds,
|
|
975
1101
|
attribute: attr_name.to_s.humanize.downcase)
|
|
976
1102
|
end
|
|
1103
|
+
|
|
1104
|
+
def duration_seconds(value)
|
|
1105
|
+
return value.to_f if value.is_a?(Numeric) || value.is_a?(ActiveSupport::Duration)
|
|
1106
|
+
|
|
1107
|
+
nil
|
|
1108
|
+
end
|
|
977
1109
|
end
|
|
978
1110
|
# rubocop:enable Metrics/ClassLength
|
|
979
1111
|
end
|