activejob-temporal 0.1.0 → 0.3.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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +1 -0
  3. data/CHANGELOG.md +29 -0
  4. data/CLAUDE.md +274 -0
  5. data/CONTRIBUTING.md +69 -0
  6. data/README.md +34 -33
  7. data/activejob-temporal.gemspec +9 -13
  8. data/api/job_payload_schema.json +51 -6
  9. data/bin/temporal-worker +25 -17
  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 +24 -6
  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 +218 -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 +30 -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_registrations.rb +66 -0
  54. data/lib/activejob/temporal/worker_runtime.rb +17 -0
  55. data/lib/activejob/temporal/workflow_enqueuer.rb +2 -1
  56. data/lib/activejob/temporal/workflow_types.rb +10 -0
  57. data/lib/activejob/temporal/workflows/aj_workflow.rb +17 -4
  58. data/lib/activejob/temporal/workflows/workflow_dependencies.rb +148 -15
  59. data/lib/activejob/temporal.rb +21 -13
  60. data/test/mutant_unit_test.rb +13 -0
  61. metadata +46 -50
@@ -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,
@@ -115,16 +116,33 @@ module ActiveJob
115
116
  )
116
117
  end
117
118
 
118
- # Builds connection keyword arguments (including TLS options).
119
+ # Builds connection keyword arguments (TLS options and API key).
119
120
  # @api private
120
121
  def connection_kwargs(configuration)
122
+ kwargs = {}
123
+
121
124
  tls = tls_options(configuration)
122
- return {} if tls.nil?
125
+ kwargs[:tls] = tls unless tls.nil?
126
+
127
+ api_key = resolve_api_key(configuration)
128
+ kwargs[:api_key] = api_key if api_key
123
129
 
124
- { tls: tls }
130
+ kwargs
125
131
  end
126
132
  private_class_method :connection_kwargs
127
133
 
134
+ # Resolves the API key: an explicit api_key wins, otherwise api_key_file is read - at
135
+ # connection build time, and again by {ActiveJob::Temporal.refresh_api_key!} when the
136
+ # token file rotates.
137
+ # @api private
138
+ def resolve_api_key(configuration)
139
+ inline = configuration.api_key if configuration.respond_to?(:api_key)
140
+ return inline unless inline.nil? || inline.to_s.strip.empty?
141
+
142
+ path = configuration.api_key_file if configuration.respond_to?(:api_key_file)
143
+ read_tls_file(path)&.strip.presence
144
+ end
145
+
128
146
  # Extracts TLS options from config or environment variables.
129
147
  # @api private
130
148
  def tls_options(configuration)
@@ -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