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.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +1 -0
  3. data/CHANGELOG.md +18 -0
  4. data/CLAUDE.md +274 -0
  5. data/CONTRIBUTING.md +69 -0
  6. data/README.md +34 -33
  7. data/activejob-temporal.gemspec +8 -12
  8. data/api/job_payload_schema.json +51 -6
  9. data/bin/temporal-worker +3 -8
  10. data/gemfiles/activejob_contract.gemfile +8 -0
  11. data/gemfiles/temporalio_contract.gemfile +7 -0
  12. data/lib/activejob/temporal/activities/aj_runner_activity.rb +75 -19
  13. data/lib/activejob/temporal/activities/dependency_status_activity.rb +36 -19
  14. data/lib/activejob/temporal/adapter.rb +4 -3
  15. data/lib/activejob/temporal/batch_enqueuer.rb +42 -33
  16. data/lib/activejob/temporal/bind_policy.rb +1 -1
  17. data/lib/activejob/temporal/cancel.rb +66 -29
  18. data/lib/activejob/temporal/certificate_watcher.rb +41 -6
  19. data/lib/activejob/temporal/client.rb +4 -3
  20. data/lib/activejob/temporal/conditional_enqueue.rb +2 -1
  21. data/lib/activejob/temporal/configurable.rb +56 -13
  22. data/lib/activejob/temporal/configuration.rb +139 -7
  23. data/lib/activejob/temporal/configured_job_compatibility.rb +91 -9
  24. data/lib/activejob/temporal/connection_worker_pool.rb +18 -1
  25. data/lib/activejob/temporal/dead_letter_queue.rb +64 -18
  26. data/lib/activejob/temporal/dependency_options.rb +126 -16
  27. data/lib/activejob/temporal/health_check_server.rb +14 -17
  28. data/lib/activejob/temporal/http_line_reader.rb +20 -2
  29. data/lib/activejob/temporal/http_request_failure_handling.rb +41 -0
  30. data/lib/activejob/temporal/inspect.rb +17 -7
  31. data/lib/activejob/temporal/job_id_validation.rb +41 -0
  32. data/lib/activejob/temporal/job_payload_dependencies.rb +23 -0
  33. data/lib/activejob/temporal/locales/en.yml +15 -3
  34. data/lib/activejob/temporal/metrics_server.rb +15 -18
  35. data/lib/activejob/temporal/middleware/chain.rb +7 -0
  36. data/lib/activejob/temporal/observability.rb +14 -2
  37. data/lib/activejob/temporal/payload.rb +64 -25
  38. data/lib/activejob/temporal/payload_encryption.rb +9 -1
  39. data/lib/activejob/temporal/payload_serializers.rb +3 -0
  40. data/lib/activejob/temporal/payload_storage.rb +4 -4
  41. data/lib/activejob/temporal/rails_environment_loader.rb +1 -8
  42. data/lib/activejob/temporal/reload_signal_queue.rb +19 -19
  43. data/lib/activejob/temporal/retry_handler_extractor.rb +18 -3
  44. data/lib/activejob/temporal/schedulable.rb +5 -7
  45. data/lib/activejob/temporal/schedule.rb +2 -2
  46. data/lib/activejob/temporal/signal_query.rb +35 -23
  47. data/lib/activejob/temporal/temporal_options.rb +26 -1
  48. data/lib/activejob/temporal/tls_file.rb +16 -16
  49. data/lib/activejob/temporal/transaction_safety.rb +102 -1
  50. data/lib/activejob/temporal/version.rb +1 -1
  51. data/lib/activejob/temporal/visibility_query.rb +16 -1
  52. data/lib/activejob/temporal/worker_pool.rb +13 -0
  53. data/lib/activejob/temporal/worker_runtime.rb +16 -0
  54. data/lib/activejob/temporal/workflow_enqueuer.rb +2 -1
  55. data/lib/activejob/temporal/workflow_types.rb +10 -0
  56. data/lib/activejob/temporal/workflows/aj_workflow.rb +17 -4
  57. data/lib/activejob/temporal/workflows/workflow_dependencies.rb +148 -15
  58. data/lib/activejob/temporal.rb +2 -13
  59. data/test/mutant_unit_test.rb +13 -0
  60. 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 the Temporal cluster cannot be reached
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.workflow_handle(workflow_id).cancel
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 valid UUID format
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
- return if job_id.is_a?(String) && job_id.match?(UUID_REGEX)
156
+ JobIdValidation.validate!(job_id)
157
+ end
123
158
 
124
- raise ArgumentError,
125
- "Invalid job_id format: expected UUID (e.g., '550e8400-e29b-41d4-a716-446655440000'), " \
126
- "got: #{job_id.inspect}"
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: Listen, debounce_seconds: DEFAULT_DEBOUNCE_SECONDS)
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 = @listener_factory.to(*directories) do |modified, added, removed|
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
- @reload_callback.call
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::Error] if connection fails (includes target, namespace, and error message)
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::Error => e
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::Error,
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 = instance_variable_get(:@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 after mutation.
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 block_given?
28
+ def configure(&block)
29
+ return config unless block
30
30
 
31
31
  @config_mvar ||= Concurrent::MVar.new(Configuration.new)
32
- @config_mvar.borrow do |configuration|
33
- configuration.in_configure_block = true
32
+ applied_configuration = nil
34
33
 
35
- begin
36
- yield(configuration)
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} while
361
- # configure blocks mutate it. Complete configuration during application boot.
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
- if metadata[:env_var] && ENV[metadata[:env_var]]
452
- value = ENV[metadata[:env_var]]
453
- return convert_env_value(value, metadata[:type])
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 == "true"
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