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
@@ -6,39 +6,121 @@ require_relative "logger"
6
6
  module ActiveJob
7
7
  module Temporal
8
8
  module ConfiguredJobCompatibility
9
+ SUPPORTED_ACTIVE_JOB_MAJOR_MINOR = [
10
+ [7, 2],
11
+ [8, 0],
12
+ [8, 1]
13
+ ].freeze
14
+ FEATURE_REPLACEMENTS = {
15
+ "chain" => "ActiveJob::Temporal.job",
16
+ "child_workflows" => "ActiveJob::Temporal.job",
17
+ "conditional_enqueue" => "JobClass.perform_later_if or an explicit condition before " \
18
+ "JobClass.set(...).perform_later"
19
+ }.freeze
20
+ ExtractedConfiguredJob = Struct.new(:job_class, :options, keyword_init: true)
21
+
9
22
  module_function
10
23
 
11
24
  def payload(value, feature:, normalize_options:)
12
- return unless configured_job?(value)
13
-
14
- log_private_api(feature)
15
-
16
- job_class = value.instance_variable_get(:@job_class)
17
- return unless active_job_class?(job_class)
25
+ configured_job = extract(value, feature: feature)
26
+ return unless configured_job
18
27
 
19
28
  {
20
- job_class: job_class.name,
21
- options: normalize_options.call(value.instance_variable_get(:@options) || {})
29
+ job_class: configured_job.job_class.name,
30
+ options: normalize_options.call(configured_job.options)
22
31
  }
23
32
  end
24
33
 
34
+ def job_class(value, feature:)
35
+ extract(value, feature: feature)&.job_class
36
+ end
37
+
25
38
  def configured_job?(value)
26
39
  defined?(ActiveJob::ConfiguredJob) && value.is_a?(ActiveJob::ConfiguredJob)
27
40
  end
28
41
 
42
+ def extract(value, feature:)
43
+ return unless configured_job?(value)
44
+
45
+ validate_active_job_version!(feature)
46
+
47
+ job_class = configured_job_instance_variable(value, :@job_class, feature)
48
+ options = configured_job_instance_variable(value, :@options, feature)
49
+
50
+ validate_job_class!(job_class, feature)
51
+ validate_options!(options, feature)
52
+
53
+ log_private_api(feature)
54
+
55
+ ExtractedConfiguredJob.new(job_class: job_class, options: options.dup)
56
+ end
57
+ private_class_method :extract
58
+
29
59
  def active_job_class?(job_class)
30
60
  job_class.is_a?(Class) && job_class < ActiveJob::Base && job_class.name
31
61
  end
32
62
 
63
+ def validate_job_class!(job_class, feature)
64
+ return if active_job_class?(job_class)
65
+
66
+ raise ArgumentError, unsupported_internals_message(feature, "@job_class")
67
+ end
68
+ private_class_method :validate_job_class!
69
+
70
+ def validate_options!(options, feature)
71
+ return if options.is_a?(Hash)
72
+
73
+ raise ArgumentError, unsupported_internals_message(feature, "@options")
74
+ end
75
+ private_class_method :validate_options!
76
+
77
+ def configured_job_instance_variable(value, name, feature)
78
+ return value.instance_variable_get(name) if value.instance_variable_defined?(name)
79
+
80
+ raise ArgumentError, unsupported_internals_message(feature, name)
81
+ end
82
+ private_class_method :configured_job_instance_variable
83
+
84
+ def validate_active_job_version!(feature)
85
+ return if supported_active_job_version?
86
+
87
+ raise ArgumentError,
88
+ "ActiveJob::ConfiguredJob internals are not supported for #{feature} on ActiveJob " \
89
+ "#{active_job_version}; use #{replacement_for(feature)} instead"
90
+ end
91
+ private_class_method :validate_active_job_version!
92
+
93
+ def supported_active_job_version?
94
+ SUPPORTED_ACTIVE_JOB_MAJOR_MINOR.include?(active_job_version.segments.first(2))
95
+ end
96
+ private_class_method :supported_active_job_version?
97
+
98
+ def active_job_version
99
+ return ActiveJob.gem_version if ActiveJob.respond_to?(:gem_version)
100
+
101
+ Gem::Version.new(ActiveJob::VERSION::STRING)
102
+ end
103
+
104
+ def unsupported_internals_message(feature, name)
105
+ "ActiveJob::ConfiguredJob internals changed for #{feature}: expected #{name}; " \
106
+ "use #{replacement_for(feature)} instead"
107
+ end
108
+ private_class_method :unsupported_internals_message
109
+
33
110
  def log_private_api(feature)
34
111
  ActiveJob::Temporal::Logger.warn(
35
112
  "active_job_configured_job_private_api",
36
113
  feature: feature,
37
- replacement: "ActiveJob::Temporal.job"
114
+ replacement: replacement_for(feature)
38
115
  )
39
116
  rescue StandardError
40
117
  nil
41
118
  end
119
+
120
+ def replacement_for(feature)
121
+ FEATURE_REPLACEMENTS.fetch(feature.to_s, "ActiveJob::Temporal.job")
122
+ end
123
+ private_class_method :replacement_for
42
124
  end
43
125
  end
44
126
  end
@@ -72,12 +72,29 @@ module ActiveJob
72
72
  connection = queue.pop
73
73
  break unless connection
74
74
 
75
- @handler.call(connection)
75
+ begin
76
+ @handler.call(connection)
77
+ rescue StandardError => e
78
+ log_handler_failure(index, e)
79
+ close_connection(connection)
80
+ end
76
81
  end
77
82
  rescue ClosedQueueError
78
83
  nil
79
84
  end
80
85
 
86
+ def log_handler_failure(index, error)
87
+ ActiveJob::Temporal::Logger.error(
88
+ "connection_worker_handler_failed",
89
+ pool: @name,
90
+ worker_index: index,
91
+ error_class: error.class.name,
92
+ message: error.message.to_s
93
+ )
94
+ rescue StandardError
95
+ nil
96
+ end
97
+
81
98
  def close_connection(connection)
82
99
  connection.close
83
100
  rescue IOError, SystemCallError
@@ -2,11 +2,16 @@
2
2
 
3
3
  require "temporalio/client"
4
4
 
5
+ require_relative "logger"
6
+ require_relative "visibility_query"
7
+ require_relative "workflow_types"
8
+
5
9
  module ActiveJob
6
10
  module Temporal
7
11
  module DeadLetterQueue
8
- WORKFLOW_TYPE = "ActiveJobTemporalDeadLetterWorkflow"
12
+ WORKFLOW_TYPE = WorkflowTypes::DEAD_LETTER
9
13
  DEFAULT_ENTRIES_LIMIT = 100
14
+ ENTRY_QUERY_CONCURRENCY = 5
10
15
 
11
16
  module_function
12
17
 
@@ -17,22 +22,24 @@ module ActiveJob
17
22
  def entries(queue: nil, limit: DEFAULT_ENTRIES_LIMIT, client: ActiveJob::Temporal.client)
18
23
  validate_limit!(limit)
19
24
 
20
- client.list_workflows(entries_query(queue)).each_with_object([]) do |workflow, entries|
21
- entry = query_workflow_entry(client, workflow)
22
- entries << entry if entry
23
- break entries if entries.size >= limit
24
- end
25
+ workflows = client.list_workflows(entries_query(queue)).first(limit)
26
+ query_workflow_entries(client, workflows)
25
27
  end
26
28
 
27
29
  def retry(job_class, job_id, queue: nil, client: ActiveJob::Temporal.client)
28
30
  handle = handle_for(job_class, job_id, client: client)
29
31
  entry = handle.query(:entry)
30
- return entry.fetch("retry_workflow_id") if retried_entry?(entry)
32
+ if retried_entry?(entry)
33
+ workflow_id = entry.fetch("retry_workflow_id")
34
+ log_retry_requested(entry, workflow_id, queue, duplicate: true)
35
+ return workflow_id
36
+ end
31
37
 
32
38
  ensure_pending_entry!(entry)
33
39
 
34
40
  workflow_id = retry_workflow_id(entry)
35
- start_retry_workflow(client, entry, workflow_id, queue)
41
+ duplicate = start_retry_workflow(client, entry, workflow_id, queue)
42
+ log_retry_requested(entry, workflow_id, queue, duplicate: duplicate)
36
43
  mark_retried_entry(handle, workflow_id)
37
44
  workflow_id
38
45
  end
@@ -56,31 +63,72 @@ module ActiveJob
56
63
 
57
64
  def entries_query(queue)
58
65
  query = ["WorkflowType='#{WORKFLOW_TYPE}'", "ExecutionStatus='Running'"]
59
- query << "TaskQueue='#{escape_query_value(queue)}'" if queue.to_s.strip.present?
66
+ query << "TaskQueue=#{VisibilityQuery.quote(queue)}" if queue.to_s.strip.present?
60
67
  query.join(" AND ")
61
68
  end
62
69
  private_class_method :entries_query
63
70
 
71
+ def query_workflow_entries(client, workflows)
72
+ entries = Array.new(workflows.size)
73
+ pending = Queue.new
74
+ workflows.each_with_index { |workflow, index| pending << [workflow, index] }
75
+
76
+ Array.new([workflows.size, ENTRY_QUERY_CONCURRENCY].min) do
77
+ worker = Thread.new do
78
+ loop do
79
+ workflow, index = pending.pop(true)
80
+ entries[index] = query_workflow_entry(client, workflow)
81
+ rescue ThreadError
82
+ break
83
+ end
84
+ end
85
+ # Failures are re-raised by Thread#value below, not lost.
86
+ worker.report_on_exception = false
87
+ worker
88
+ end.each(&:value)
89
+
90
+ entries
91
+ end
92
+ private_class_method :query_workflow_entries
93
+
64
94
  def query_workflow_entry(client, workflow)
65
95
  client.workflow_handle(workflow.id, run_id: workflow_run_id(workflow)).query(:entry)
66
- rescue Temporalio::Error
67
- nil
68
96
  end
69
97
  private_class_method :query_workflow_entry
70
98
 
71
99
  def start_retry_workflow(client, entry, workflow_id, queue)
72
100
  client.start_workflow(
73
- ActiveJob::Temporal::Workflows::AjWorkflow,
101
+ WorkflowTypes::ACTIVE_JOB,
74
102
  retry_payload(entry),
75
103
  id: workflow_id,
76
104
  task_queue: retry_task_queue(entry, queue),
77
105
  id_conflict_policy: Temporalio::WorkflowIDConflictPolicy::FAIL
78
106
  )
107
+ false
79
108
  rescue StandardError => e
80
109
  raise unless workflow_already_started?(e)
110
+
111
+ true
81
112
  end
82
113
  private_class_method :start_retry_workflow
83
114
 
115
+ def log_retry_requested(entry, workflow_id, queue, duplicate:)
116
+ Logger.log_event(
117
+ "dead_letter_retry_requested",
118
+ {
119
+ entry_id: entry.fetch("id"),
120
+ workflow_id: workflow_id,
121
+ job_class: entry.dig("payload", "job_class"),
122
+ job_id: entry.dig("payload", "job_id"),
123
+ task_queue: retry_task_queue(entry, queue),
124
+ duplicate: duplicate
125
+ }.compact
126
+ )
127
+ rescue StandardError
128
+ nil
129
+ end
130
+ private_class_method :log_retry_requested
131
+
84
132
  def mark_retried_entry(handle, workflow_id)
85
133
  handle.signal(:mark_retried, workflow_id)
86
134
  rescue StandardError => e
@@ -146,16 +194,14 @@ module ActiveJob
146
194
  end
147
195
  private_class_method :validate_limit!
148
196
 
149
- def escape_query_value(value)
150
- value.to_s.gsub("'", "''")
151
- end
152
- private_class_method :escape_query_value
153
-
154
197
  def workflow_already_started?(error)
155
198
  (defined?(Temporalio::Error::WorkflowAlreadyStartedError) &&
156
199
  error.is_a?(Temporalio::Error::WorkflowAlreadyStartedError)) ||
157
200
  (defined?(Temporalio::Client::WorkflowAlreadyStartedError) &&
158
- error.is_a?(Temporalio::Client::WorkflowAlreadyStartedError))
201
+ error.is_a?(Temporalio::Client::WorkflowAlreadyStartedError)) ||
202
+ (defined?(Temporalio::Error::RPCError::Code::ALREADY_EXISTS) &&
203
+ error.respond_to?(:code) &&
204
+ error.code == Temporalio::Error::RPCError::Code::ALREADY_EXISTS)
159
205
  end
160
206
  private_class_method :workflow_already_started?
161
207
  end
@@ -9,8 +9,9 @@ module ActiveJob
9
9
  JOB_CLASS_NAME_PATTERN = /\A[A-Z]\w*(?:::[A-Z]\w*)*\z/
10
10
  SAFE_ID_PATTERN = /\A[A-Za-z0-9_.:-]+\z/
11
11
  FAILURE_POLICIES = %i[fail ignore].freeze
12
+ WAIT_OPTION_KEYS = %i[timeout initial_interval max_interval backoff].freeze
12
13
 
13
- attr_reader :temporal_dependencies, :temporal_dependency_failure_policy
14
+ attr_reader :temporal_dependencies, :temporal_dependency_failure_policy, :temporal_dependency_wait
14
15
 
15
16
  def self.normalize(depends_on)
16
17
  dependencies = depends_on.is_a?(Array) ? depends_on : [depends_on]
@@ -28,6 +29,17 @@ module ActiveJob
28
29
  raise ArgumentError, "on_dependency_failure must be :fail or :ignore"
29
30
  end
30
31
 
32
+ def self.normalize_wait_options(options)
33
+ raise ArgumentError, "dependency_wait must be a hash" unless options.is_a?(Hash)
34
+
35
+ normalized = options.each_with_object({}) do |(key, value), wait_options|
36
+ normalized_key = normalize_wait_option_key(key)
37
+ wait_options[normalized_key] = normalize_wait_option_value(normalized_key, value)
38
+ end
39
+ validate_wait_interval_order!(normalized)
40
+ normalized
41
+ end
42
+
31
43
  def self.normalize_dependency(dependency)
32
44
  return normalize_job_dependency(dependency) if dependency.is_a?(ActiveJob::Base)
33
45
  return normalize_hash_dependency(dependency) if dependency.is_a?(Hash)
@@ -55,10 +67,12 @@ module ActiveJob
55
67
  normalized = {}
56
68
  job_id = hash_value(dependency, :job_id)
57
69
  workflow_id = hash_value(dependency, :workflow_id)
70
+ run_id = hash_value(dependency, :run_id)
58
71
  job_class = hash_value(dependency, :job_class)
59
72
 
60
73
  normalized[:job_id] = normalize_id(job_id, "job_id") if job_id
61
74
  normalized[:workflow_id] = normalize_id(workflow_id, "workflow_id") if workflow_id
75
+ normalized[:run_id] = normalize_id(run_id, "run_id") if run_id
62
76
  normalized[:job_class] = normalize_job_class(job_class) if job_class
63
77
 
64
78
  if normalized[:job_id].nil? && normalized[:workflow_id].nil?
@@ -97,36 +111,132 @@ module ActiveJob
97
111
  end
98
112
  private_class_method :hash_value
99
113
 
100
- def set(options = {})
101
- enqueue_options = options.dup
102
- dependencies_configured = enqueue_options.key?(:depends_on)
103
- failure_policy_configured = enqueue_options.key?(:on_dependency_failure)
114
+ def self.normalize_wait_option_key(key)
115
+ normalized_key = key.to_sym
116
+ return normalized_key if WAIT_OPTION_KEYS.include?(normalized_key)
104
117
 
105
- normalized_dependencies = if dependencies_configured
106
- DependencyOptions.normalize(enqueue_options.delete(:depends_on))
107
- end
108
- normalized_failure_policy = normalize_dependency_failure_policy(
109
- enqueue_options.delete(:on_dependency_failure),
110
- failure_policy_configured
111
- )
118
+ raise ArgumentError, "dependency_wait supports: #{WAIT_OPTION_KEYS.join(', ')}"
119
+ rescue NoMethodError
120
+ raise ArgumentError, "dependency_wait supports: #{WAIT_OPTION_KEYS.join(', ')}"
121
+ end
122
+ private_class_method :normalize_wait_option_key
123
+
124
+ def self.normalize_wait_option_value(key, value)
125
+ return normalize_wait_backoff(value) if key == :backoff
112
126
 
113
- if failure_policy_configured && !dependencies_configured && normalized_dependencies.nil?
114
- raise ArgumentError, "on_dependency_failure requires depends_on"
127
+ normalize_wait_duration(value, key)
128
+ end
129
+ private_class_method :normalize_wait_option_value
130
+
131
+ def self.normalize_wait_duration(value, key)
132
+ unless value.is_a?(Numeric) || value.is_a?(ActiveSupport::Duration)
133
+ raise ArgumentError, "dependency_wait #{key} must be a duration"
115
134
  end
116
135
 
136
+ seconds = value.to_f
137
+ raise ArgumentError, "dependency_wait #{key} must be positive" unless seconds.positive?
138
+
139
+ seconds
140
+ end
141
+ private_class_method :normalize_wait_duration
142
+
143
+ def self.normalize_wait_backoff(value)
144
+ backoff = Float(value)
145
+ raise ArgumentError, "dependency_wait backoff must be greater than or equal to 1" if backoff < 1.0
146
+
147
+ backoff
148
+ rescue ArgumentError, TypeError
149
+ raise ArgumentError, "dependency_wait backoff must be greater than or equal to 1"
150
+ end
151
+ private_class_method :normalize_wait_backoff
152
+
153
+ def self.validate_wait_interval_order!(wait_options)
154
+ initial_interval = wait_options[:initial_interval]
155
+ max_interval = wait_options[:max_interval]
156
+ return unless initial_interval && max_interval
157
+ return if max_interval >= initial_interval
158
+
159
+ raise ArgumentError, "dependency_wait max_interval must be greater than or equal to initial_interval"
160
+ end
161
+ private_class_method :validate_wait_interval_order!
162
+
163
+ def set(options = {})
164
+ enqueue_options = options.dup
165
+ dependency_options = normalize_dependency_set_options(enqueue_options)
166
+ validate_dependency_set_options!(dependency_options)
167
+
117
168
  super(enqueue_options).tap do
118
- @temporal_dependencies = normalized_dependencies if dependencies_configured
119
- @temporal_dependency_failure_policy = normalized_failure_policy || :fail if dependencies_configured
169
+ apply_dependency_set_options(dependency_options)
120
170
  end
121
171
  end
122
172
 
123
173
  private
124
174
 
175
+ def normalize_dependency_set_options(enqueue_options)
176
+ dependencies_configured = enqueue_options.key?(:depends_on)
177
+ failure_policy_configured = enqueue_options.key?(:on_dependency_failure)
178
+ dependency_wait_configured = enqueue_options.key?(:dependency_wait)
179
+
180
+ {
181
+ dependencies_configured: dependencies_configured,
182
+ failure_policy_configured: failure_policy_configured,
183
+ dependency_wait_configured: dependency_wait_configured,
184
+ dependencies: normalize_dependencies(enqueue_options, dependencies_configured),
185
+ failure_policy: normalize_dependency_failure_policy(
186
+ enqueue_options.delete(:on_dependency_failure),
187
+ failure_policy_configured
188
+ ),
189
+ dependency_wait: normalize_dependency_wait(enqueue_options, dependency_wait_configured)
190
+ }
191
+ end
192
+
193
+ def normalize_dependencies(enqueue_options, configured)
194
+ return unless configured
195
+
196
+ DependencyOptions.normalize(enqueue_options.delete(:depends_on))
197
+ end
198
+
199
+ def normalize_dependency_wait(enqueue_options, configured)
200
+ return unless configured
201
+
202
+ DependencyOptions.normalize_wait_options(enqueue_options.delete(:dependency_wait))
203
+ end
204
+
125
205
  def normalize_dependency_failure_policy(policy, configured)
126
206
  return unless configured
127
207
 
128
208
  DependencyOptions.normalize_failure_policy(policy)
129
209
  end
210
+
211
+ def validate_dependency_set_options!(dependency_options)
212
+ dependencies_configured = dependency_options[:dependencies_configured]
213
+ validate_dependency_requirement!(
214
+ dependency_options[:failure_policy_configured],
215
+ dependencies_configured,
216
+ "on_dependency_failure requires depends_on"
217
+ )
218
+ validate_dependency_requirement!(
219
+ dependency_options[:dependency_wait_configured],
220
+ dependencies_configured,
221
+ "dependency_wait requires depends_on"
222
+ )
223
+ end
224
+
225
+ def validate_dependency_requirement!(configured, dependencies_configured, message)
226
+ return if !configured || dependencies_configured
227
+
228
+ raise ArgumentError, message
229
+ end
230
+
231
+ def apply_dependency_set_options(dependency_options)
232
+ if dependency_options[:dependencies_configured]
233
+ @temporal_dependencies = dependency_options[:dependencies]
234
+ @temporal_dependency_failure_policy = dependency_options[:failure_policy] || :fail
235
+ end
236
+ return unless dependency_options[:dependency_wait_configured]
237
+
238
+ @temporal_dependency_wait = dependency_options[:dependency_wait]
239
+ end
130
240
  end
131
241
  end
132
242
  end
@@ -6,17 +6,21 @@ require "socket"
6
6
 
7
7
  require_relative "connection_worker_pool"
8
8
  require_relative "bind_policy"
9
+ require_relative "http_request_failure_handling"
9
10
  require_relative "http_line_reader"
10
11
 
11
12
  module ActiveJob
12
13
  module Temporal
13
14
  class HealthCheckServer
14
15
  include HttpLineReader
16
+ include HttpRequestFailureHandling
15
17
 
16
18
  DEFAULT_BIND_ADDRESS = "127.0.0.1"
17
19
  READ_TIMEOUT_SECONDS = 1
18
20
  CONNECTION_WORKERS = 4
19
21
  CONNECTION_QUEUE_SIZE = 16
22
+ REQUEST_FAILURE_EVENT = "health_check_request_failed"
23
+ REQUEST_FAILURE_FORMAT = :json
20
24
 
21
25
  attr_reader :port, :bind_address
22
26
 
@@ -84,25 +88,24 @@ module ActiveJob
84
88
  connection_pool.enqueue(server.accept)
85
89
  rescue IOError, Errno::EBADF
86
90
  break
91
+ rescue SystemCallError => e
92
+ # Transient accept failures (fd exhaustion, aborted connections) must not kill the listener.
93
+ ActiveJob::Temporal::Logger.error(
94
+ "health_check_accept_failed", error_class: e.class.name, message: e.message.to_s
95
+ )
96
+ sleep 0.05
87
97
  end
88
98
  ensure
89
99
  @mutex.synchronize { @running = false if @server }
90
100
  end
91
101
 
92
- def serve_client(client)
93
- handle_client(client)
94
- rescue IOError, SystemCallError
95
- nil
96
- ensure
97
- client&.close
98
- end
99
-
100
102
  def handle_client(client)
101
- request_line = read_line(client)
103
+ deadline = request_deadline
104
+ request_line = read_line(client, deadline)
102
105
  return unless request_line
103
106
 
104
107
  method, path = request_line.split.first(2)
105
- drain_headers(client)
108
+ drain_headers(client, deadline)
106
109
 
107
110
  unless method && path
108
111
  write_json(client, 400, { error: "bad_request" })
@@ -128,13 +131,6 @@ module ActiveJob
128
131
  payload[:worker_running] ? 200 : 503
129
132
  end
130
133
 
131
- def drain_headers(client)
132
- loop do
133
- line = read_line(client)
134
- break if line.nil? || line == "\r\n" || line == "\n"
135
- end
136
- end
137
-
138
134
  def write_json(client, status, payload, body: true)
139
135
  json = JSON.generate(payload)
140
136
  response = "HTTP/1.1 #{status} #{reason_phrase(status)}\r\n"
@@ -151,6 +147,7 @@ module ActiveJob
151
147
  400 => "Bad Request",
152
148
  404 => "Not Found",
153
149
  405 => "Method Not Allowed",
150
+ 500 => "Internal Server Error",
154
151
  503 => "Service Unavailable"
155
152
  }.fetch(status)
156
153
  end
@@ -2,11 +2,21 @@
2
2
 
3
3
  module ActiveJob
4
4
  module Temporal
5
+ # Bounded line reading for the embedded HTTP endpoints: one deadline for the
6
+ # whole request plus caps on line length and header count, so a slow or
7
+ # oversized client cannot hold a connection worker.
5
8
  module HttpLineReader
9
+ MAX_LINE_BYTES = 8_192
10
+ MAX_HEADER_LINES = 100
11
+
6
12
  private
7
13
 
8
- def read_line(client)
9
- deadline = monotonic_time + self.class.const_get(:READ_TIMEOUT_SECONDS)
14
+ # @return [Float] monotonic deadline covering an entire request
15
+ def request_deadline
16
+ monotonic_time + self.class.const_get(:READ_TIMEOUT_SECONDS)
17
+ end
18
+
19
+ def read_line(client, deadline)
10
20
  buffer = +""
11
21
 
12
22
  loop do
@@ -24,10 +34,18 @@ module ActiveJob
24
34
  else
25
35
  buffer << chunk
26
36
  return buffer if chunk == "\n"
37
+ return if buffer.bytesize >= MAX_LINE_BYTES
27
38
  end
28
39
  end
29
40
  end
30
41
 
42
+ def drain_headers(client, deadline)
43
+ MAX_HEADER_LINES.times do
44
+ line = read_line(client, deadline)
45
+ break if line.nil? || line == "\r\n" || line == "\n"
46
+ end
47
+ end
48
+
31
49
  def monotonic_time
32
50
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
33
51
  end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveJob
4
+ module Temporal
5
+ module HttpRequestFailureHandling
6
+ private
7
+
8
+ def serve_client(client)
9
+ handle_client(client)
10
+ rescue IOError, SystemCallError
11
+ nil
12
+ rescue StandardError => e
13
+ log_request_failure(e)
14
+ write_failure_response(client)
15
+ ensure
16
+ client&.close
17
+ end
18
+
19
+ def write_failure_response(client)
20
+ case self.class::REQUEST_FAILURE_FORMAT
21
+ when :json
22
+ write_json(client, 500, { error: "internal_server_error" })
23
+ when :text
24
+ write_text(client, 500, "internal_server_error\n")
25
+ end
26
+ rescue IOError, SystemCallError
27
+ nil
28
+ end
29
+
30
+ def log_request_failure(error)
31
+ ActiveJob::Temporal::Logger.error(
32
+ self.class::REQUEST_FAILURE_EVENT,
33
+ error_class: error.class.name,
34
+ message: error.message.to_s
35
+ )
36
+ rescue StandardError
37
+ nil
38
+ end
39
+ end
40
+ end
41
+ end