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.
- checksums.yaml +4 -4
- data/AGENTS.md +1 -0
- data/CHANGELOG.md +18 -0
- data/CLAUDE.md +274 -0
- data/CONTRIBUTING.md +69 -0
- data/README.md +34 -33
- data/activejob-temporal.gemspec +8 -12
- data/api/job_payload_schema.json +51 -6
- data/bin/temporal-worker +3 -8
- 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 +4 -3
- data/lib/activejob/temporal/conditional_enqueue.rb +2 -1
- data/lib/activejob/temporal/configurable.rb +56 -13
- data/lib/activejob/temporal/configuration.rb +139 -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 +15 -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_runtime.rb +16 -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 +2 -13
- data/test/mutant_unit_test.rb +13 -0
- 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
|
-
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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 =
|
|
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)).
|
|
21
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|