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
@@ -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
@@ -2,13 +2,13 @@
2
2
 
3
3
  require "temporalio/client/workflow_execution_status"
4
4
  require "temporalio/error"
5
+ require_relative "job_id_validation"
5
6
  require_relative "visibility_query"
6
7
  require_relative "workflow_id_builder"
7
8
 
8
9
  module ActiveJob
9
10
  module Temporal
10
11
  module Inspect
11
- 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
12
12
  JOB_CLASS_NAME_PATTERN = /\A[A-Z]\w*(?:::[A-Z]\w*)*\z/
13
13
  WORKFLOW_STATES = {
14
14
  Temporalio::Client::WorkflowExecutionStatus::RUNNING => :running,
@@ -26,7 +26,8 @@ module ActiveJob
26
26
  validate_job_id!(job_id)
27
27
 
28
28
  client = ActiveJob::Temporal.client
29
- describe_default_workflow(client, job_class, job_id) ||
29
+ describe_schedule_execution_workflow(client, job_id) ||
30
+ describe_default_workflow(client, job_class, job_id) ||
30
31
  describe_search_attribute_workflow(client, job_class, job_id)
31
32
  rescue ArgumentError
32
33
  raise
@@ -40,7 +41,9 @@ module ActiveJob
40
41
  def completed?(job_class, job_id) = workflow_state?(job_class, job_id, :completed)
41
42
 
42
43
  def failed?(job_class, job_id) = workflow_state?(job_class, job_id, :failed)
44
+ end
43
45
 
46
+ class << self
44
47
  private
45
48
 
46
49
  def workflow_state?(job_class, job_id, state)
@@ -58,11 +61,7 @@ module ActiveJob
58
61
  end
59
62
 
60
63
  def validate_job_id!(job_id)
61
- return if job_id.is_a?(String) && job_id.match?(UUID_REGEX)
62
-
63
- raise ArgumentError,
64
- "Invalid job_id format: expected UUID (e.g., '550e8400-e29b-41d4-a716-446655440000'), " \
65
- "got: #{job_id.inspect}"
64
+ JobIdValidation.validate!(job_id)
66
65
  end
67
66
 
68
67
  def describe_default_workflow(client, job_class, job_id)
@@ -73,6 +72,17 @@ module ActiveJob
73
72
  nil
74
73
  end
75
74
 
75
+ def describe_schedule_execution_workflow(client, job_id)
76
+ workflow_reference = JobIdValidation.schedule_execution_reference(job_id)
77
+ return unless workflow_reference
78
+
79
+ describe_workflow(client, workflow_reference)
80
+ rescue StandardError => e
81
+ raise unless rpc_not_found?(e)
82
+
83
+ nil
84
+ end
85
+
76
86
  def describe_search_attribute_workflow(client, job_class, job_id)
77
87
  workflow_reference = find_workflow_reference(client, job_class, job_id)
78
88
  return unless workflow_reference
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "workflow_id_builder"
4
+
5
+ module ActiveJob
6
+ module Temporal
7
+ module JobIdValidation
8
+ MAX_JOB_ID_LENGTH = WorkflowIdBuilder::MAX_WORKFLOW_ID_LENGTH
9
+ CONTROL_CHARACTER_PATTERN = WorkflowIdBuilder::CONTROL_CHARACTER_PATTERN
10
+ SCHEDULE_WORKFLOW_ID_PREFIX = "ajschwf:"
11
+
12
+ module_function
13
+
14
+ def validate!(job_id)
15
+ unless job_id.is_a?(String)
16
+ raise ArgumentError, "job_id must be a String, got #{job_id.class}: #{job_id.inspect}"
17
+ end
18
+
19
+ raise ArgumentError, "job_id must be valid UTF-8" unless job_id.valid_encoding?
20
+ raise ArgumentError, "job_id must not be blank" if job_id.strip.empty?
21
+
22
+ if job_id.length > MAX_JOB_ID_LENGTH
23
+ raise ArgumentError, "job_id maximum length is #{MAX_JOB_ID_LENGTH} characters (got #{job_id.length})"
24
+ end
25
+
26
+ return unless job_id.match?(CONTROL_CHARACTER_PATTERN)
27
+
28
+ raise ArgumentError, "job_id control characters are not allowed (got #{job_id.inspect})"
29
+ end
30
+
31
+ def schedule_execution_reference(job_id)
32
+ return unless job_id.start_with?(SCHEDULE_WORKFLOW_ID_PREFIX)
33
+
34
+ workflow_id, separator, run_id = job_id.rpartition(":")
35
+ return if separator.empty? || workflow_id.empty? || run_id.empty?
36
+
37
+ { workflow_id: workflow_id, run_id: run_id }
38
+ end
39
+ end
40
+ end
41
+ end
@@ -16,6 +16,7 @@ module ActiveJob
16
16
  payload[:dependencies] = dependencies.map { |dependency| enrich_dependency(dependency) }
17
17
  policy = job.respond_to?(:temporal_dependency_failure_policy) ? job.temporal_dependency_failure_policy : :fail
18
18
  payload[:dependency_failure_policy] = policy.to_s
19
+ payload[:dependency_wait] = dependency_wait_options(job)
19
20
  end
20
21
 
21
22
  def enrich_dependency(dependency)
@@ -35,6 +36,28 @@ module ActiveJob
35
36
  WorkflowIdBuilder.validate!(workflow_id)
36
37
  workflow_id
37
38
  end
39
+
40
+ def dependency_wait_options(job)
41
+ default_dependency_wait_options.merge(job_dependency_wait_options(job))
42
+ end
43
+
44
+ def default_dependency_wait_options
45
+ {
46
+ timeout: @config.dependency_wait_timeout.to_f,
47
+ initial_interval: @config.dependency_wait_initial_interval.to_f,
48
+ max_interval: @config.dependency_wait_max_interval.to_f,
49
+ backoff: @config.dependency_wait_backoff.to_f
50
+ }
51
+ end
52
+
53
+ def job_dependency_wait_options(job)
54
+ return {} unless job.respond_to?(:temporal_dependency_wait)
55
+
56
+ dependency_wait = job.temporal_dependency_wait
57
+ return {} unless dependency_wait.is_a?(Hash)
58
+
59
+ dependency_wait
60
+ end
38
61
  end
39
62
  end
40
63
  end
@@ -101,18 +101,30 @@ en:
101
101
  not_a_duration: "must be a duration (e.g., 1.day or 6.hours), got: %{value}"
102
102
  duration_not_positive: "must be positive (got: %{seconds} seconds). Use values like 1.hour or 7.days"
103
103
 
104
+ dependency_wait_timeout:
105
+ not_a_duration: "must be a duration (e.g., 10.minutes or 1.day), got: %{value}"
106
+ duration_not_positive: "must be positive (got: %{seconds} seconds). Use values like 1.minute or 1.day"
107
+
108
+ dependency_wait_initial_interval:
109
+ not_a_duration: "must be a duration (e.g., 5.seconds or 1.minute), got: %{value}"
110
+ duration_not_positive: "must be positive (got: %{seconds} seconds). Use values like 5.seconds or 1.minute"
111
+
112
+ dependency_wait_max_interval:
113
+ not_a_duration: "must be a duration (e.g., 30.seconds or 5.minutes), got: %{value}"
114
+ duration_not_positive: "must be positive (got: %{seconds} seconds). Use values like 30.seconds or 5.minutes"
115
+
104
116
  tls_cert_path:
105
117
  requires_key_path: "requires tls_key_path so the client certificate and private key rotate together"
106
118
  invalid_path: "must be a non-empty file path, got: %{value}"
107
- unreadable_path: "must point to a readable, non-symlink file, got: %{value}"
119
+ unreadable_path: "must resolve to a readable regular file, got: %{value}"
108
120
 
109
121
  tls_key_path:
110
122
  invalid_path: "must be a non-empty file path, got: %{value}"
111
- unreadable_path: "must point to a readable, non-symlink file, got: %{value}"
123
+ unreadable_path: "must resolve to a readable regular file, got: %{value}"
112
124
 
113
125
  tls_server_root_ca_cert_path:
114
126
  invalid_path: "must be a non-empty file path, got: %{value}"
115
- unreadable_path: "must point to a readable, non-symlink file, got: %{value}"
127
+ unreadable_path: "must resolve to a readable regular file, got: %{value}"
116
128
 
117
129
  tls_domain:
118
130
  blank: "must be present when configured"
@@ -124,3 +136,18 @@ en:
124
136
  tls_reload_signal:
125
137
  blank: "must be present when configured"
126
138
  invalid: "must be a signal name like HUP or USR1, got: %{value}"
139
+
140
+ api_key_file:
141
+ invalid_path: "must be a non-empty file path, got: %{value}"
142
+ unreadable_path: "must resolve to a readable regular file, got: %{value}"
143
+
144
+ api_key_watch:
145
+ not_boolean: "must be true or false, got: %{value}"
146
+ requires_path: "requires api_key_file to watch"
147
+
148
+ worker_activejob_workloads:
149
+ not_boolean: "must be true or false, got: %{value}"
150
+ requires_activities: "requires worker_activities when disabled - the worker would have nothing to register"
151
+
152
+ graceful_shutdown_period:
153
+ graceful_shutdown_period_negative: "must be >= 0 seconds, got: %{value}"
@@ -5,18 +5,22 @@ require "socket"
5
5
 
6
6
  require_relative "connection_worker_pool"
7
7
  require_relative "bind_policy"
8
+ require_relative "http_request_failure_handling"
8
9
  require_relative "http_line_reader"
9
10
 
10
11
  module ActiveJob
11
12
  module Temporal
12
13
  class MetricsServer
13
14
  include HttpLineReader
15
+ include HttpRequestFailureHandling
14
16
 
15
17
  DEFAULT_BIND_ADDRESS = "127.0.0.1"
16
18
  READ_TIMEOUT_SECONDS = 1
17
19
  CONTENT_TYPE = "text/plain; version=0.0.4"
18
20
  CONNECTION_WORKERS = 4
19
21
  CONNECTION_QUEUE_SIZE = 16
22
+ REQUEST_FAILURE_EVENT = "metrics_request_failed"
23
+ REQUEST_FAILURE_FORMAT = :text
20
24
 
21
25
  attr_reader :port, :bind_address
22
26
 
@@ -86,25 +90,24 @@ module ActiveJob
86
90
  connection_pool.enqueue(server.accept)
87
91
  rescue IOError, Errno::EBADF
88
92
  break
93
+ rescue SystemCallError => e
94
+ # Transient accept failures (fd exhaustion, aborted connections) must not kill the listener.
95
+ ActiveJob::Temporal::Logger.error(
96
+ "metrics_accept_failed", error_class: e.class.name, message: e.message.to_s
97
+ )
98
+ sleep 0.05
89
99
  end
90
100
  ensure
91
101
  @mutex.synchronize { @running = false if @server }
92
102
  end
93
103
 
94
- def serve_client(client)
95
- handle_client(client)
96
- rescue IOError, SystemCallError
97
- nil
98
- ensure
99
- client&.close
100
- end
101
-
102
104
  def handle_client(client)
103
- request_line = read_line(client)
105
+ deadline = request_deadline
106
+ request_line = read_line(client, deadline)
104
107
  return unless request_line
105
108
 
106
109
  method, path = request_line.split.first(2)
107
- drain_headers(client)
110
+ drain_headers(client, deadline)
108
111
 
109
112
  unless method && path
110
113
  write_text(client, 400, "bad_request\n")
@@ -121,13 +124,6 @@ module ActiveJob
121
124
  end
122
125
  end
123
126
 
124
- def drain_headers(client)
125
- loop do
126
- line = read_line(client)
127
- break if line.nil? || line == "\r\n" || line == "\n"
128
- end
129
- end
130
-
131
127
  def write_text(client, status, payload, body: true)
132
128
  response = "HTTP/1.1 #{status} #{reason_phrase(status)}\r\n"
133
129
  response << "Content-Type: #{CONTENT_TYPE}\r\n"
@@ -142,7 +138,8 @@ module ActiveJob
142
138
  200 => "OK",
143
139
  400 => "Bad Request",
144
140
  404 => "Not Found",
145
- 405 => "Method Not Allowed"
141
+ 405 => "Method Not Allowed",
142
+ 500 => "Internal Server Error"
146
143
  }.fetch(status)
147
144
  end
148
145
  end
@@ -16,6 +16,13 @@ module ActiveJob
16
16
  entries.each { |entry| add(entry) }
17
17
  end
18
18
 
19
+ def initialize_copy(original)
20
+ super
21
+ @entries = original.instance_variable_get(:@entries).dup
22
+ @entry_indexes_by_key = original.instance_variable_get(:@entry_indexes_by_key).dup
23
+ @compiled_call_chain = compile_call_chain
24
+ end
25
+
19
26
  def add(middleware, *args, **kwargs, &block)
20
27
  key = entry_key(middleware, args, kwargs, block)
21
28
  callable = build_callable(middleware, args, kwargs, block)
@@ -164,6 +164,13 @@ module ActiveJob
164
164
 
165
165
  def initialize
166
166
  @adapters = []
167
+ @stop_replaced_adapters = true
168
+ end
169
+
170
+ def initialize_copy(original)
171
+ super
172
+ @adapters = original.adapters.dup
173
+ @stop_replaced_adapters = false
167
174
  end
168
175
 
169
176
  def use(name, **)
@@ -185,7 +192,7 @@ module ActiveJob
185
192
  end
186
193
 
187
194
  def reset!
188
- adapters.each(&:stop!)
195
+ adapters.each(&:stop!) if @stop_replaced_adapters
189
196
  adapters.clear
190
197
  end
191
198
 
@@ -193,11 +200,16 @@ module ActiveJob
193
200
  adapters.each(&:validate!)
194
201
  end
195
202
 
203
+ def finalize_configuration_copy!
204
+ @stop_replaced_adapters = true
205
+ self
206
+ end
207
+
196
208
  private
197
209
 
198
210
  def replace_adapter(adapter)
199
211
  previous_adapter = self.adapter(adapter.name)
200
- previous_adapter&.stop!
212
+ previous_adapter&.stop! if @stop_replaced_adapters
201
213
  adapters.delete(previous_adapter)
202
214
  adapters << adapter
203
215
  end