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.
- checksums.yaml +4 -4
- data/AGENTS.md +1 -0
- data/CHANGELOG.md +29 -0
- data/CLAUDE.md +274 -0
- data/CONTRIBUTING.md +69 -0
- data/README.md +34 -33
- data/activejob-temporal.gemspec +9 -13
- data/api/job_payload_schema.json +51 -6
- data/bin/temporal-worker +25 -17
- data/gemfiles/activejob_contract.gemfile +8 -0
- data/gemfiles/temporalio_contract.gemfile +7 -0
- data/lib/activejob/temporal/activities/aj_runner_activity.rb +75 -19
- data/lib/activejob/temporal/activities/dependency_status_activity.rb +36 -19
- data/lib/activejob/temporal/adapter.rb +4 -3
- data/lib/activejob/temporal/batch_enqueuer.rb +42 -33
- data/lib/activejob/temporal/bind_policy.rb +1 -1
- data/lib/activejob/temporal/cancel.rb +66 -29
- data/lib/activejob/temporal/certificate_watcher.rb +41 -6
- data/lib/activejob/temporal/client.rb +24 -6
- data/lib/activejob/temporal/conditional_enqueue.rb +2 -1
- data/lib/activejob/temporal/configurable.rb +56 -13
- data/lib/activejob/temporal/configuration.rb +218 -7
- data/lib/activejob/temporal/configured_job_compatibility.rb +91 -9
- data/lib/activejob/temporal/connection_worker_pool.rb +18 -1
- data/lib/activejob/temporal/dead_letter_queue.rb +64 -18
- data/lib/activejob/temporal/dependency_options.rb +126 -16
- data/lib/activejob/temporal/health_check_server.rb +14 -17
- data/lib/activejob/temporal/http_line_reader.rb +20 -2
- data/lib/activejob/temporal/http_request_failure_handling.rb +41 -0
- data/lib/activejob/temporal/inspect.rb +17 -7
- data/lib/activejob/temporal/job_id_validation.rb +41 -0
- data/lib/activejob/temporal/job_payload_dependencies.rb +23 -0
- data/lib/activejob/temporal/locales/en.yml +30 -3
- data/lib/activejob/temporal/metrics_server.rb +15 -18
- data/lib/activejob/temporal/middleware/chain.rb +7 -0
- data/lib/activejob/temporal/observability.rb +14 -2
- data/lib/activejob/temporal/payload.rb +64 -25
- data/lib/activejob/temporal/payload_encryption.rb +9 -1
- data/lib/activejob/temporal/payload_serializers.rb +3 -0
- data/lib/activejob/temporal/payload_storage.rb +4 -4
- data/lib/activejob/temporal/rails_environment_loader.rb +1 -8
- data/lib/activejob/temporal/reload_signal_queue.rb +19 -19
- data/lib/activejob/temporal/retry_handler_extractor.rb +18 -3
- data/lib/activejob/temporal/schedulable.rb +5 -7
- data/lib/activejob/temporal/schedule.rb +2 -2
- data/lib/activejob/temporal/signal_query.rb +35 -23
- data/lib/activejob/temporal/temporal_options.rb +26 -1
- data/lib/activejob/temporal/tls_file.rb +16 -16
- data/lib/activejob/temporal/transaction_safety.rb +102 -1
- data/lib/activejob/temporal/version.rb +1 -1
- data/lib/activejob/temporal/visibility_query.rb +16 -1
- data/lib/activejob/temporal/worker_pool.rb +13 -0
- data/lib/activejob/temporal/worker_registrations.rb +66 -0
- data/lib/activejob/temporal/worker_runtime.rb +17 -0
- data/lib/activejob/temporal/workflow_enqueuer.rb +2 -1
- data/lib/activejob/temporal/workflow_types.rb +10 -0
- data/lib/activejob/temporal/workflows/aj_workflow.rb +17 -4
- data/lib/activejob/temporal/workflows/workflow_dependencies.rb +148 -15
- data/lib/activejob/temporal.rb +21 -13
- data/test/mutant_unit_test.rb +13 -0
- 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
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|