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
|
@@ -6,14 +6,115 @@ module ActiveJob
|
|
|
6
6
|
module Temporal
|
|
7
7
|
module TransactionSafety
|
|
8
8
|
module QueueAdapterSetter
|
|
9
|
+
INHERITED_TRANSACTION_SETTING_MISSING = Object.new.freeze
|
|
10
|
+
|
|
9
11
|
def queue_adapter=(adapter)
|
|
12
|
+
previous_transaction_setting = enqueue_after_transaction_commit
|
|
13
|
+
|
|
10
14
|
super.tap do
|
|
11
|
-
|
|
15
|
+
if temporal_queue_adapter?(adapter)
|
|
16
|
+
apply_temporal_transaction_safety(previous_transaction_setting)
|
|
17
|
+
else
|
|
18
|
+
restore_temporal_transaction_safety
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def enqueue_after_transaction_commit=(value)
|
|
24
|
+
super.tap do
|
|
25
|
+
next if setting_temporal_transaction_safety?
|
|
26
|
+
|
|
27
|
+
@activejob_temporal_transaction_setting_explicit = true
|
|
28
|
+
@activejob_temporal_transaction_setting_changed_after_safety = true if temporal_transaction_safety_applied?
|
|
12
29
|
end
|
|
13
30
|
end
|
|
14
31
|
|
|
15
32
|
private
|
|
16
33
|
|
|
34
|
+
def apply_temporal_transaction_safety(previous_transaction_setting)
|
|
35
|
+
return if temporal_transaction_safety_applied?
|
|
36
|
+
return if temporal_transaction_setting_explicit?
|
|
37
|
+
|
|
38
|
+
@activejob_temporal_previous_transaction_setting =
|
|
39
|
+
transaction_setting_before_temporal_safety(previous_transaction_setting)
|
|
40
|
+
apply_enqueue_after_transaction_commit_for_temporal(true)
|
|
41
|
+
@activejob_temporal_transaction_safety_applied = true
|
|
42
|
+
@activejob_temporal_transaction_setting_changed_after_safety = false
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def restore_temporal_transaction_safety
|
|
46
|
+
unless temporal_transaction_safety_applied?
|
|
47
|
+
restore_inherited_temporal_transaction_safety
|
|
48
|
+
return
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
unless @activejob_temporal_transaction_setting_changed_after_safety
|
|
52
|
+
apply_enqueue_after_transaction_commit_for_temporal(@activejob_temporal_previous_transaction_setting)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
remove_instance_variable(:@activejob_temporal_previous_transaction_setting)
|
|
56
|
+
@activejob_temporal_transaction_safety_applied = false
|
|
57
|
+
@activejob_temporal_transaction_setting_changed_after_safety = false
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def restore_inherited_temporal_transaction_safety
|
|
61
|
+
return if temporal_transaction_setting_explicit_locally?
|
|
62
|
+
|
|
63
|
+
inherited_setting = inherited_temporal_transaction_previous_setting
|
|
64
|
+
return if inherited_setting.equal?(INHERITED_TRANSACTION_SETTING_MISSING)
|
|
65
|
+
|
|
66
|
+
apply_enqueue_after_transaction_commit_for_temporal(inherited_setting)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def apply_enqueue_after_transaction_commit_for_temporal(value)
|
|
70
|
+
@activejob_temporal_setting_transaction_safety = true
|
|
71
|
+
self.enqueue_after_transaction_commit = value
|
|
72
|
+
ensure
|
|
73
|
+
@activejob_temporal_setting_transaction_safety = false
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def transaction_setting_before_temporal_safety(current_setting)
|
|
77
|
+
inherited_setting = inherited_temporal_transaction_previous_setting
|
|
78
|
+
return current_setting if inherited_setting.equal?(INHERITED_TRANSACTION_SETTING_MISSING)
|
|
79
|
+
|
|
80
|
+
inherited_setting
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def inherited_temporal_transaction_previous_setting
|
|
84
|
+
return INHERITED_TRANSACTION_SETTING_MISSING unless superclass.respond_to?(
|
|
85
|
+
:temporal_transaction_safety_applied?, true
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
if superclass.send(:temporal_transaction_safety_applied?)
|
|
89
|
+
superclass.send(:temporal_previous_transaction_setting)
|
|
90
|
+
else
|
|
91
|
+
superclass.send(:inherited_temporal_transaction_previous_setting)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def temporal_previous_transaction_setting
|
|
96
|
+
@activejob_temporal_previous_transaction_setting
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def temporal_transaction_setting_explicit?
|
|
100
|
+
return true if temporal_transaction_setting_explicit_locally?
|
|
101
|
+
return false unless superclass.respond_to?(:temporal_transaction_setting_explicit?, true)
|
|
102
|
+
|
|
103
|
+
superclass.send(:temporal_transaction_setting_explicit?)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def temporal_transaction_setting_explicit_locally?
|
|
107
|
+
@activejob_temporal_transaction_setting_explicit == true
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def temporal_transaction_safety_applied?
|
|
111
|
+
@activejob_temporal_transaction_safety_applied == true
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def setting_temporal_transaction_safety?
|
|
115
|
+
@activejob_temporal_setting_transaction_safety == true
|
|
116
|
+
end
|
|
117
|
+
|
|
17
118
|
def temporal_queue_adapter?(adapter)
|
|
18
119
|
case adapter
|
|
19
120
|
when Symbol, String
|
|
@@ -3,10 +3,25 @@
|
|
|
3
3
|
module ActiveJob
|
|
4
4
|
module Temporal
|
|
5
5
|
module VisibilityQuery
|
|
6
|
+
SAFE_VALUE_PATTERN = /\A[A-Za-z0-9_.:-]+\z/
|
|
7
|
+
|
|
6
8
|
module_function
|
|
7
9
|
|
|
10
|
+
# Quotes a value for use in a Temporal visibility list filter.
|
|
11
|
+
#
|
|
12
|
+
# Escaping alone is not enough: Temporal's list filter grammar also treats
|
|
13
|
+
# backslashes as escapes, so a value containing one can break out of the
|
|
14
|
+
# quoted literal. Values are restricted to an allowlist instead.
|
|
15
|
+
#
|
|
16
|
+
# @param value [#to_s] Value to embed in a list filter
|
|
17
|
+
# @return [String] Single-quoted literal
|
|
18
|
+
# @raise [ArgumentError] if the value contains characters outside the allowlist
|
|
8
19
|
def quote(value)
|
|
9
|
-
|
|
20
|
+
string_value = value.to_s
|
|
21
|
+
return "'#{string_value}'" if string_value.match?(SAFE_VALUE_PATTERN)
|
|
22
|
+
|
|
23
|
+
raise ArgumentError,
|
|
24
|
+
"visibility query values may only contain letters, numbers, underscore, hyphen, period, and colon"
|
|
10
25
|
end
|
|
11
26
|
end
|
|
12
27
|
end
|
|
@@ -140,6 +140,19 @@ module ActiveJob
|
|
|
140
140
|
end
|
|
141
141
|
|
|
142
142
|
validate_public_binds!
|
|
143
|
+
validate_port_ranges!
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Each worker binds base_port + index, so overlapping ranges would make
|
|
147
|
+
# workers fight over the same port and crash-loop.
|
|
148
|
+
def validate_port_ranges!
|
|
149
|
+
return unless @health_check_port && @metrics_port
|
|
150
|
+
return unless @health_check_port < @metrics_port + @size && @metrics_port < @health_check_port + @size
|
|
151
|
+
|
|
152
|
+
raise ArgumentError,
|
|
153
|
+
"health_check_port and metrics_port ranges overlap for #{@size} workers: " \
|
|
154
|
+
"health check #{@health_check_port}-#{@health_check_port + @size - 1}, " \
|
|
155
|
+
"metrics #{@metrics_port}-#{@metrics_port + @size - 1}"
|
|
143
156
|
end
|
|
144
157
|
|
|
145
158
|
def positive_integer(value, name)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "temporalio/activity"
|
|
4
|
+
|
|
5
|
+
module ActiveJob
|
|
6
|
+
module Temporal
|
|
7
|
+
# Resolves which workflows and activities a worker registers, from configuration.
|
|
8
|
+
#
|
|
9
|
+
# By default a worker hosts the built-in ActiveJob workloads. `worker_activities`
|
|
10
|
+
# adds custom activity classes - for example activities invoked by workflows owned
|
|
11
|
+
# by another service, where only the activity name and JSON payloads travel over
|
|
12
|
+
# the wire. Setting `worker_activejob_workloads = false` turns the worker into an
|
|
13
|
+
# activities-only worker that hosts nothing but those custom activities.
|
|
14
|
+
module WorkerRegistrations
|
|
15
|
+
Result = Struct.new(:workflows, :activities, keyword_init: true)
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
# Configuration validation rejects the nothing-to-register combination up front
|
|
19
|
+
# (see Configuration#validate_worker_registration_settings).
|
|
20
|
+
#
|
|
21
|
+
# @param configuration [ActiveJob::Temporal::Configuration]
|
|
22
|
+
# @return [Result] workflows and activities to pass to Temporalio::Worker
|
|
23
|
+
# @raise [WorkerRegistrationError] when a worker_activities entry does not resolve
|
|
24
|
+
# to a Temporalio::Activity::Definition subclass
|
|
25
|
+
def resolve(configuration)
|
|
26
|
+
workflows = []
|
|
27
|
+
activities = custom_activities(configuration)
|
|
28
|
+
|
|
29
|
+
if configuration.worker_activejob_workloads
|
|
30
|
+
workflows += [
|
|
31
|
+
Workflows::AjWorkflow,
|
|
32
|
+
Workflows::DeadLetterWorkflow
|
|
33
|
+
]
|
|
34
|
+
activities += [
|
|
35
|
+
Activities::RateLimitActivity,
|
|
36
|
+
Activities::DependencyStatusActivity,
|
|
37
|
+
Activities::AjRunnerActivity
|
|
38
|
+
]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
Result.new(workflows: workflows, activities: activities)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def custom_activities(configuration)
|
|
47
|
+
Array(configuration.worker_activities).map { |entry| resolve_activity(entry) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def resolve_activity(entry)
|
|
51
|
+
activity = entry.is_a?(String) ? constantize_activity(entry) : entry
|
|
52
|
+
return activity if activity.is_a?(Class) && activity < Temporalio::Activity::Definition
|
|
53
|
+
|
|
54
|
+
raise WorkerRegistrationError,
|
|
55
|
+
"worker_activities entry #{entry.inspect} is not a Temporalio::Activity::Definition subclass"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def constantize_activity(name)
|
|
59
|
+
name.constantize
|
|
60
|
+
rescue NameError
|
|
61
|
+
raise WorkerRegistrationError, "worker_activities entry #{name.inspect} does not resolve to a class"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../temporal"
|
|
4
|
+
require_relative "rails_environment_loader"
|
|
5
|
+
require_relative "certificate_watcher"
|
|
6
|
+
require_relative "reload_signal_queue"
|
|
7
|
+
require_relative "worker_client_reloader"
|
|
8
|
+
require_relative "worker_pool"
|
|
9
|
+
require_relative "metrics_server"
|
|
10
|
+
require_relative "worker_health"
|
|
11
|
+
require_relative "health_check_server"
|
|
12
|
+
require_relative "workflows/aj_workflow"
|
|
13
|
+
require_relative "workflows/dead_letter_workflow"
|
|
14
|
+
require_relative "activities/rate_limit_activity"
|
|
15
|
+
require_relative "activities/dependency_status_activity"
|
|
16
|
+
require_relative "activities/aj_runner_activity"
|
|
17
|
+
require_relative "worker_registrations"
|
|
@@ -7,6 +7,7 @@ require_relative "job_payload_builder"
|
|
|
7
7
|
require_relative "observability"
|
|
8
8
|
require_relative "workflow_enqueuer_batch"
|
|
9
9
|
require_relative "workflow_id_builder"
|
|
10
|
+
require_relative "workflow_types"
|
|
10
11
|
|
|
11
12
|
module ActiveJob
|
|
12
13
|
module Temporal
|
|
@@ -128,7 +129,7 @@ module ActiveJob
|
|
|
128
129
|
# Starts the Temporal workflow with the given options.
|
|
129
130
|
# @api private
|
|
130
131
|
def start_workflow(job, payload, options)
|
|
131
|
-
workflow_class =
|
|
132
|
+
workflow_class = WorkflowTypes::ACTIVE_JOB
|
|
132
133
|
handle = start_temporal_workflow(workflow_class, job, payload, options)
|
|
133
134
|
|
|
134
135
|
log_enqueued(job, options, payload, duplicate: false)
|
|
@@ -8,6 +8,7 @@ require "temporalio/workflow"
|
|
|
8
8
|
|
|
9
9
|
require_relative "../activities/rate_limit_activity"
|
|
10
10
|
require_relative "dead_letter_support"
|
|
11
|
+
require_relative "dead_letter_workflow"
|
|
11
12
|
require_relative "workflow_continue_as_new"
|
|
12
13
|
require_relative "workflow_child_workflows"
|
|
13
14
|
require_relative "workflow_chaining"
|
|
@@ -109,7 +110,8 @@ module ActiveJob
|
|
|
109
110
|
# @option payload [String] :job_class Fully-qualified job class name (required)
|
|
110
111
|
# @option payload [String] :job_id Unique job identifier (required)
|
|
111
112
|
# @option payload [String] :queue_name Target queue name (required)
|
|
112
|
-
# @option payload [
|
|
113
|
+
# @option payload [Hash] :active_job Full ActiveJob serialized payload
|
|
114
|
+
# @option payload [Array] :arguments Legacy serialized job arguments
|
|
113
115
|
# @option payload [Hash] :default_activity_options Global activity timeout defaults (required)
|
|
114
116
|
# @option payload [Hash] :retry_policy Retry policy for activity execution (required)
|
|
115
117
|
# @option payload [Hash] :temporal_options Per-job timeout configuration (optional)
|
|
@@ -127,14 +129,14 @@ module ActiveJob
|
|
|
127
129
|
# The timer persists across worker restarts and does not block worker threads.
|
|
128
130
|
#
|
|
129
131
|
# @example Immediate execution
|
|
130
|
-
# execute({ job_class: "MyJob", job_id: "123",
|
|
132
|
+
# execute({ job_class: "MyJob", job_id: "123", active_job: { "arguments" => ["arg1"] } })
|
|
131
133
|
#
|
|
132
134
|
# @example Scheduled execution (non-blocking sleep)
|
|
133
135
|
# execute({
|
|
134
136
|
# job_class: "MyJob",
|
|
135
137
|
# job_id: "123",
|
|
136
138
|
# scheduled_at: "2025-10-31T12:00:00Z",
|
|
137
|
-
#
|
|
139
|
+
# active_job: { "arguments" => [] }
|
|
138
140
|
# })
|
|
139
141
|
# # Workflow sleeps until scheduled time without consuming worker resources
|
|
140
142
|
#
|
|
@@ -175,13 +177,24 @@ module ActiveJob
|
|
|
175
177
|
def schedule_execution_payload(payload)
|
|
176
178
|
return payload unless payload_value(payload, :schedule_id)
|
|
177
179
|
|
|
178
|
-
execution_job_id =
|
|
180
|
+
execution_job_id = schedule_execution_identity
|
|
179
181
|
payload.merge(
|
|
180
182
|
"job_id" => execution_job_id,
|
|
181
183
|
"schedule_execution_job_id" => execution_job_id
|
|
182
184
|
)
|
|
183
185
|
end
|
|
184
186
|
|
|
187
|
+
def schedule_execution_identity
|
|
188
|
+
info = Temporalio::Workflow.info
|
|
189
|
+
run_id = if info.respond_to?(:first_execution_run_id)
|
|
190
|
+
info.first_execution_run_id
|
|
191
|
+
elsif info.respond_to?(:run_id)
|
|
192
|
+
info.run_id
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
[info.workflow_id, run_id].compact.join(":")
|
|
196
|
+
end
|
|
197
|
+
|
|
185
198
|
# Extracts scheduled execution time from payload.
|
|
186
199
|
# @api private
|
|
187
200
|
def extract_scheduled_time(payload)
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "time"
|
|
4
|
+
require "timeout"
|
|
3
5
|
require "temporalio/error"
|
|
4
6
|
|
|
5
7
|
require_relative "../activities/dependency_status_activity"
|
|
@@ -11,8 +13,11 @@ module ActiveJob
|
|
|
11
13
|
DEPENDENCY_CHECK_ACTIVITY_TIMEOUT = 30.0
|
|
12
14
|
DEPENDENCY_CHECK_RETRY_POLICY = Temporalio::RetryPolicy.new(max_attempts: 1)
|
|
13
15
|
DEPENDENCY_WAIT_INTERVAL = 10.0
|
|
16
|
+
DEPENDENCY_WAIT_TIMEOUT = 86_400.0
|
|
17
|
+
DEPENDENCY_WAIT_MAX_INTERVAL = 60.0
|
|
18
|
+
DEPENDENCY_WAIT_BACKOFF = 2.0
|
|
14
19
|
DEPENDENCY_NOT_FOUND_MAX_CHECKS = 6
|
|
15
|
-
COMPLETED_DEPENDENCY_STATES = %w[completed].freeze
|
|
20
|
+
COMPLETED_DEPENDENCY_STATES = %w[completed continued_as_new].freeze
|
|
16
21
|
FAILED_DEPENDENCY_STATES = %w[failed canceled terminated timed_out unknown].freeze
|
|
17
22
|
NOT_FOUND_DEPENDENCY_STATES = %w[not_found].freeze
|
|
18
23
|
|
|
@@ -23,16 +28,19 @@ module ActiveJob
|
|
|
23
28
|
return if dependencies.empty?
|
|
24
29
|
|
|
25
30
|
workflow_state["phase"] = "waiting_dependencies"
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
31
|
+
wait_options = dependency_wait_options(payload)
|
|
32
|
+
deadline = dependency_wait_deadline(wait_options)
|
|
33
|
+
begin
|
|
34
|
+
with_dependency_wait_timeout(wait_options, deadline) do
|
|
35
|
+
wait_for_dependency_statuses(payload, dependencies, wait_options)
|
|
36
|
+
end
|
|
37
|
+
rescue Timeout::Error
|
|
38
|
+
timed_out_statuses = dependency_timeout_statuses(dependencies)
|
|
39
|
+
if fail_on_dependency_failure?(payload)
|
|
40
|
+
fail_for_dependencies!(timed_out_statuses)
|
|
41
|
+
else
|
|
42
|
+
workflow_state.delete("dependency_wait")
|
|
43
|
+
end
|
|
36
44
|
end
|
|
37
45
|
end
|
|
38
46
|
|
|
@@ -50,6 +58,116 @@ module ActiveJob
|
|
|
50
58
|
)
|
|
51
59
|
end
|
|
52
60
|
|
|
61
|
+
def wait_for_dependency_statuses(payload, dependencies, wait_options)
|
|
62
|
+
not_found_counts = dependency_wait_not_found_counts
|
|
63
|
+
wait_interval = dependency_wait_current_interval(wait_options)
|
|
64
|
+
loop do
|
|
65
|
+
wait_while_paused
|
|
66
|
+
statuses = check_dependency_statuses(dependencies)
|
|
67
|
+
expired_not_found_statuses = expired_not_found_statuses(statuses, not_found_counts)
|
|
68
|
+
failed_statuses = failed_dependency_statuses(statuses) + expired_not_found_statuses
|
|
69
|
+
fail_for_dependencies!(failed_statuses) if fail_on_dependency_failure?(payload) && failed_statuses.any?
|
|
70
|
+
break if dependencies_satisfied?(statuses, expired_not_found_statuses)
|
|
71
|
+
|
|
72
|
+
persist_dependency_wait_state(not_found_counts, wait_interval)
|
|
73
|
+
continue_as_new_if_needed(payload)
|
|
74
|
+
Temporalio::Workflow.sleep(wait_interval)
|
|
75
|
+
wait_interval = next_dependency_wait_interval(wait_interval, wait_options)
|
|
76
|
+
end
|
|
77
|
+
workflow_state.delete("dependency_wait")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def with_dependency_wait_timeout(wait_options, deadline, &)
|
|
81
|
+
remaining_timeout = dependency_wait_remaining_timeout(deadline)
|
|
82
|
+
raise Timeout::Error, "dependency wait timed out" unless remaining_timeout.positive?
|
|
83
|
+
|
|
84
|
+
Temporalio::Workflow.timeout(
|
|
85
|
+
remaining_timeout,
|
|
86
|
+
Timeout::Error,
|
|
87
|
+
"dependency wait timed out after #{wait_options[:timeout]} seconds",
|
|
88
|
+
summary: "Dependency wait timeout",
|
|
89
|
+
&
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def dependency_wait_options(payload)
|
|
94
|
+
configured_options = payload[:dependency_wait] || payload["dependency_wait"] || {}
|
|
95
|
+
initial_interval = positive_float_option(configured_options, :initial_interval, DEPENDENCY_WAIT_INTERVAL)
|
|
96
|
+
max_interval = positive_float_option(configured_options, :max_interval, DEPENDENCY_WAIT_MAX_INTERVAL)
|
|
97
|
+
{
|
|
98
|
+
timeout: positive_float_option(configured_options, :timeout, DEPENDENCY_WAIT_TIMEOUT),
|
|
99
|
+
initial_interval: initial_interval,
|
|
100
|
+
max_interval: [max_interval, initial_interval].max,
|
|
101
|
+
backoff: backoff_option(configured_options)
|
|
102
|
+
}
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def positive_float_option(options, key, default)
|
|
106
|
+
value = options[key] || options[key.to_s]
|
|
107
|
+
return default unless value
|
|
108
|
+
|
|
109
|
+
number = Float(value)
|
|
110
|
+
number.positive? ? number : default
|
|
111
|
+
rescue ArgumentError, TypeError
|
|
112
|
+
default
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def backoff_option(options)
|
|
116
|
+
value = options[:backoff] || options["backoff"]
|
|
117
|
+
return DEPENDENCY_WAIT_BACKOFF unless value
|
|
118
|
+
|
|
119
|
+
backoff = Float(value)
|
|
120
|
+
backoff >= 1.0 ? backoff : DEPENDENCY_WAIT_BACKOFF
|
|
121
|
+
rescue ArgumentError, TypeError
|
|
122
|
+
DEPENDENCY_WAIT_BACKOFF
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def dependency_wait_not_found_counts
|
|
126
|
+
Hash.new(0).merge(dependency_wait_state["not_found_counts"] || {})
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def dependency_wait_current_interval(wait_options)
|
|
130
|
+
current_interval = dependency_wait_state["current_interval"]
|
|
131
|
+
return wait_options[:initial_interval] unless current_interval
|
|
132
|
+
|
|
133
|
+
interval = Float(current_interval)
|
|
134
|
+
interval.positive? ? interval : wait_options[:initial_interval]
|
|
135
|
+
rescue ArgumentError, TypeError
|
|
136
|
+
wait_options[:initial_interval]
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def dependency_wait_state
|
|
140
|
+
workflow_state["dependency_wait"] ||= {}
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def dependency_wait_deadline(wait_options)
|
|
144
|
+
deadline_at = dependency_wait_state["deadline_at"]
|
|
145
|
+
return Time.iso8601(deadline_at) if deadline_at
|
|
146
|
+
|
|
147
|
+
persist_dependency_wait_deadline(wait_options)
|
|
148
|
+
rescue ArgumentError, TypeError
|
|
149
|
+
persist_dependency_wait_deadline(wait_options)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def persist_dependency_wait_deadline(wait_options)
|
|
153
|
+
deadline = Temporalio::Workflow.now + wait_options[:timeout]
|
|
154
|
+
dependency_wait_state["deadline_at"] = deadline.iso8601
|
|
155
|
+
deadline
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def dependency_wait_remaining_timeout(deadline)
|
|
159
|
+
deadline - Temporalio::Workflow.now
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def persist_dependency_wait_state(not_found_counts, wait_interval)
|
|
163
|
+
dependency_wait_state["not_found_counts"] = not_found_counts
|
|
164
|
+
dependency_wait_state["current_interval"] = wait_interval
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def next_dependency_wait_interval(wait_interval, wait_options)
|
|
168
|
+
[wait_interval * wait_options[:backoff], wait_options[:max_interval]].min
|
|
169
|
+
end
|
|
170
|
+
|
|
53
171
|
def dependencies_satisfied?(statuses, expired_not_found_statuses)
|
|
54
172
|
expired_not_found_keys = expired_not_found_statuses.map { |status| dependency_status_key(status) }
|
|
55
173
|
statuses.all? do |status|
|
|
@@ -79,16 +197,23 @@ module ActiveJob
|
|
|
79
197
|
end
|
|
80
198
|
|
|
81
199
|
def dependency_state(status)
|
|
82
|
-
status
|
|
200
|
+
status_value(status, :state)
|
|
83
201
|
end
|
|
84
202
|
|
|
85
203
|
def dependency_status_key(status)
|
|
86
|
-
job_class = status
|
|
87
|
-
job_id = status
|
|
204
|
+
job_class = status_value(status, :job_class)
|
|
205
|
+
job_id = status_value(status, :job_id)
|
|
206
|
+
workflow_id = status_value(status, :workflow_id)
|
|
207
|
+
run_id = status_value(status, :run_id)
|
|
88
208
|
return "#{job_class}:#{job_id}" if job_class && job_id
|
|
89
209
|
return job_id if job_id
|
|
210
|
+
return [workflow_id, run_id].compact.join(":") if workflow_id
|
|
211
|
+
|
|
212
|
+
status.hash
|
|
213
|
+
end
|
|
90
214
|
|
|
91
|
-
|
|
215
|
+
def status_value(status, key)
|
|
216
|
+
status[key.to_s] || status[key]
|
|
92
217
|
end
|
|
93
218
|
|
|
94
219
|
def fail_on_dependency_failure?(payload)
|
|
@@ -109,6 +234,14 @@ module ActiveJob
|
|
|
109
234
|
non_retryable: true
|
|
110
235
|
)
|
|
111
236
|
end
|
|
237
|
+
|
|
238
|
+
def dependency_timeout_statuses(dependencies)
|
|
239
|
+
dependencies.map do |dependency|
|
|
240
|
+
dependency.each_with_object({ "state" => "timed_out" }) do |(key, value), status|
|
|
241
|
+
status[key.to_s] = value
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
112
245
|
end
|
|
113
246
|
end
|
|
114
247
|
end
|
data/lib/activejob/temporal.rb
CHANGED
|
@@ -9,17 +9,9 @@ require_relative "temporal/configurable"
|
|
|
9
9
|
require_relative "temporal/client"
|
|
10
10
|
require_relative "temporal/logger"
|
|
11
11
|
require_relative "temporal/bind_policy"
|
|
12
|
-
require_relative "temporal/rails_environment_loader"
|
|
13
12
|
require_relative "temporal/tls_file"
|
|
14
|
-
require_relative "temporal/certificate_watcher"
|
|
15
|
-
require_relative "temporal/reload_signal_queue"
|
|
16
|
-
require_relative "temporal/worker_client_reloader"
|
|
17
|
-
require_relative "temporal/worker_pool"
|
|
18
13
|
require_relative "temporal/audit_log"
|
|
19
|
-
require_relative "temporal/metrics_server"
|
|
20
14
|
require_relative "temporal/observability"
|
|
21
|
-
require_relative "temporal/worker_health"
|
|
22
|
-
require_relative "temporal/health_check_server"
|
|
23
15
|
require_relative "temporal/middleware"
|
|
24
16
|
require_relative "temporal/payload_serializers"
|
|
25
17
|
require_relative "temporal/rate_limit_options"
|
|
@@ -36,6 +28,7 @@ require_relative "temporal/child_workflow_options"
|
|
|
36
28
|
require_relative "temporal/chain_options"
|
|
37
29
|
require_relative "temporal/dependency_options"
|
|
38
30
|
require_relative "temporal/workflow_identity"
|
|
31
|
+
require_relative "temporal/workflow_types"
|
|
39
32
|
require_relative "temporal/job_payload_builder"
|
|
40
33
|
require_relative "temporal/schedule_options"
|
|
41
34
|
require_relative "temporal/conditional_enqueue"
|
|
@@ -44,17 +37,13 @@ require_relative "temporal/temporal_options"
|
|
|
44
37
|
require_relative "temporal/schedule"
|
|
45
38
|
require_relative "temporal/schedulable"
|
|
46
39
|
require_relative "temporal/workflow_id_builder"
|
|
40
|
+
require_relative "temporal/job_id_validation"
|
|
47
41
|
require_relative "temporal/batch_enqueue_result"
|
|
48
42
|
require_relative "temporal/batch_enqueuer"
|
|
49
43
|
require_relative "temporal/workflow_enqueuer_batch"
|
|
50
44
|
require_relative "temporal/workflow_enqueuer"
|
|
51
45
|
require_relative "temporal/adapter"
|
|
52
46
|
require_relative "temporal/transaction_safety"
|
|
53
|
-
require_relative "temporal/workflows/aj_workflow"
|
|
54
|
-
require_relative "temporal/workflows/dead_letter_workflow"
|
|
55
|
-
require_relative "temporal/activities/rate_limit_activity"
|
|
56
|
-
require_relative "temporal/activities/dependency_status_activity"
|
|
57
|
-
require_relative "temporal/activities/aj_runner_activity"
|
|
58
47
|
require_relative "temporal/cancel"
|
|
59
48
|
require_relative "temporal/inspect"
|
|
60
49
|
require_relative "temporal/signal_query"
|
|
@@ -110,6 +99,11 @@ module ActiveJob
|
|
|
110
99
|
# @see Cancel.cancel
|
|
111
100
|
class TemporalConnectionError < Error; end
|
|
112
101
|
|
|
102
|
+
# Raised when a worker_activities entry cannot be used as a Temporal activity.
|
|
103
|
+
#
|
|
104
|
+
# @see WorkerRegistrations.resolve
|
|
105
|
+
class WorkerRegistrationError < Error; end
|
|
106
|
+
|
|
113
107
|
extend Configurable
|
|
114
108
|
|
|
115
109
|
class << self
|
|
@@ -168,6 +162,20 @@ module ActiveJob
|
|
|
168
162
|
fresh_client
|
|
169
163
|
end
|
|
170
164
|
|
|
165
|
+
# Re-reads the API key (api_key or api_key_file) and applies it to the memoized
|
|
166
|
+
# client's live connection. No reconnect happens - the SDK sends the header per-RPC
|
|
167
|
+
# from the connection's stored options. Worker processes call this from the
|
|
168
|
+
# api_key_watch file watcher; a long-lived enqueue-side process using a rotating
|
|
169
|
+
# token file must call it on its own schedule (nothing watches the file there).
|
|
170
|
+
#
|
|
171
|
+
# @return [String, nil] the freshly resolved API key
|
|
172
|
+
def refresh_api_key!
|
|
173
|
+
fresh_key = Client.resolve_api_key(config)
|
|
174
|
+
client.connection.api_key = fresh_key
|
|
175
|
+
Logger.log_event("api_key_refreshed")
|
|
176
|
+
fresh_key
|
|
177
|
+
end
|
|
178
|
+
|
|
171
179
|
# Cancels a running or scheduled job by job ID.
|
|
172
180
|
#
|
|
173
181
|
# This method requests cancellation for the Temporal workflow associated with the job.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mutant/minitest/coverage"
|
|
4
|
+
require_relative "../spec/spec_helper"
|
|
5
|
+
|
|
6
|
+
# mutant-minitest discovers test/**/*_test.rb, while the migrated suite keeps
|
|
7
|
+
# the existing spec/ split for CI and developer workflows.
|
|
8
|
+
Minitest::Spec.cover "ActiveJob::Temporal::WorkflowIdBuilder#build"
|
|
9
|
+
Minitest::Spec.cover "ActiveJob::Temporal::WorkflowIdBuilder.default_for"
|
|
10
|
+
|
|
11
|
+
Dir[File.expand_path("../spec/unit/**/*_spec.rb", __dir__)].each do |path|
|
|
12
|
+
require path
|
|
13
|
+
end
|