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
|
@@ -131,18 +131,33 @@ module ActiveJob
|
|
|
131
131
|
handlers = job_class.rescue_handlers
|
|
132
132
|
return [] unless handlers.respond_to?(:reverse_each)
|
|
133
133
|
|
|
134
|
-
|
|
134
|
+
cached = cache[job_class]
|
|
135
|
+
return cached[:entries] if fresh_cache_entry?(cached, handlers)
|
|
135
136
|
|
|
136
137
|
@cache_mutex.synchronize do
|
|
137
138
|
cached = cache[job_class]
|
|
138
|
-
return cached[:entries] if cached
|
|
139
|
+
return cached[:entries] if fresh_cache_entry?(cached, handlers)
|
|
139
140
|
|
|
140
141
|
entries = yield(handlers).map(&:freeze).freeze
|
|
141
|
-
cache[job_class] = {
|
|
142
|
+
cache[job_class] = {
|
|
143
|
+
handlers: handlers,
|
|
144
|
+
signature: rescue_handlers_signature(handlers),
|
|
145
|
+
entries: entries
|
|
146
|
+
}.freeze
|
|
142
147
|
entries
|
|
143
148
|
end
|
|
144
149
|
end
|
|
145
150
|
|
|
151
|
+
# ActiveJob replaces the rescue_handlers array on every declaration, so
|
|
152
|
+
# identity is a sound (and allocation-free) freshness check; the signature
|
|
153
|
+
# comparison stays as a fallback for in-place mutation.
|
|
154
|
+
def fresh_cache_entry?(cached, handlers)
|
|
155
|
+
return false unless cached
|
|
156
|
+
return true if cached[:handlers].equal?(handlers)
|
|
157
|
+
|
|
158
|
+
cached[:signature] == rescue_handlers_signature(handlers)
|
|
159
|
+
end
|
|
160
|
+
|
|
146
161
|
def rescue_handlers_signature(handlers)
|
|
147
162
|
handlers.reverse_each.map do |class_or_name, handler|
|
|
148
163
|
[class_or_name, handler.object_id]
|
|
@@ -10,22 +10,20 @@ module ActiveJob
|
|
|
10
10
|
end
|
|
11
11
|
|
|
12
12
|
module ClassMethods
|
|
13
|
-
def
|
|
13
|
+
def temporal_schedule(options = nil, **kwargs)
|
|
14
|
+
return @temporal_schedule if options.nil? && kwargs.empty?
|
|
15
|
+
|
|
14
16
|
schedule_options = normalize_schedule_options(options, kwargs)
|
|
15
17
|
@temporal_schedule = ActiveJob::Temporal::Schedule.new(self, schedule_options)
|
|
16
18
|
end
|
|
17
19
|
|
|
18
|
-
def temporal_schedule
|
|
19
|
-
@temporal_schedule
|
|
20
|
-
end
|
|
21
|
-
|
|
22
20
|
def create_temporal_schedule(options = nil, **kwargs)
|
|
23
21
|
if options || kwargs.any?
|
|
24
22
|
schedule_options = merged_schedule_options(normalize_schedule_options(options, kwargs))
|
|
25
23
|
return ActiveJob::Temporal::Schedule.new(self, schedule_options).create
|
|
26
24
|
end
|
|
27
25
|
|
|
28
|
-
raise ArgumentError, "No
|
|
26
|
+
raise ArgumentError, "No temporal_schedule defined for #{name}" unless temporal_schedule
|
|
29
27
|
|
|
30
28
|
temporal_schedule.create
|
|
31
29
|
end
|
|
@@ -43,7 +41,7 @@ module ActiveJob
|
|
|
43
41
|
when Hash
|
|
44
42
|
options.merge(kwargs)
|
|
45
43
|
else
|
|
46
|
-
raise ArgumentError, "
|
|
44
|
+
raise ArgumentError, "temporal_schedule options must be a Hash"
|
|
47
45
|
end
|
|
48
46
|
end
|
|
49
47
|
|
|
@@ -9,7 +9,7 @@ require_relative "job_payload_builder"
|
|
|
9
9
|
require_relative "logger"
|
|
10
10
|
require_relative "schedule_options"
|
|
11
11
|
require_relative "search_attributes"
|
|
12
|
-
require_relative "
|
|
12
|
+
require_relative "workflow_types"
|
|
13
13
|
|
|
14
14
|
module ActiveJob
|
|
15
15
|
module Temporal
|
|
@@ -96,7 +96,7 @@ module ActiveJob
|
|
|
96
96
|
payload = annotate_scheduled_payload(payload, workflow_id)
|
|
97
97
|
|
|
98
98
|
Temporalio::Client::Schedule::Action::StartWorkflow.new(
|
|
99
|
-
|
|
99
|
+
WorkflowTypes::ACTIVE_JOB,
|
|
100
100
|
payload,
|
|
101
101
|
id: workflow_id,
|
|
102
102
|
task_queue: Adapter.resolve_task_queue(job, config: @config),
|
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "temporalio/error"
|
|
4
|
+
require_relative "job_id_validation"
|
|
4
5
|
require_relative "visibility_query"
|
|
5
6
|
require_relative "workflow_id_builder"
|
|
6
7
|
|
|
7
8
|
module ActiveJob
|
|
8
9
|
module Temporal
|
|
9
10
|
module SignalQuery
|
|
10
|
-
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
|
|
11
11
|
JOB_CLASS_NAME_PATTERN = /\A[A-Z]\w*(?:::[A-Z]\w*)*\z/
|
|
12
12
|
HANDLER_NAME_PATTERN = /\A[a-zA-Z_]\w*\z/
|
|
13
13
|
DEFAULT_REJECT_CONDITION = Object.new.freeze
|
|
14
|
+
WORKFLOW_NOT_FOUND = Object.new.freeze
|
|
14
15
|
|
|
15
16
|
class << self
|
|
16
17
|
def signal(job_class, job_id, signal_name, *)
|
|
@@ -57,33 +58,48 @@ module ActiveJob
|
|
|
57
58
|
raise ActiveJob::Temporal::TemporalConnectionError,
|
|
58
59
|
"Failed to update Temporal workflow for job_id #{job_id}: #{e.message}"
|
|
59
60
|
end
|
|
61
|
+
end
|
|
60
62
|
|
|
63
|
+
class << self
|
|
61
64
|
private
|
|
62
65
|
|
|
63
|
-
def with_running_workflow_handle(job_class, job_id)
|
|
66
|
+
def with_running_workflow_handle(job_class, job_id, &)
|
|
64
67
|
client = ActiveJob::Temporal.client
|
|
65
|
-
|
|
68
|
+
result = yield_schedule_execution_handle(client, job_id, &)
|
|
69
|
+
return result unless result.equal?(WORKFLOW_NOT_FOUND)
|
|
66
70
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
rescue Temporalio::Error::RPCError => e
|
|
70
|
-
raise unless rpc_not_found?(e)
|
|
71
|
-
end
|
|
71
|
+
result = yield_handle(client.workflow_handle(default_workflow_id(job_class, job_id), run_id: nil), &)
|
|
72
|
+
return result unless result.equal?(WORKFLOW_NOT_FOUND)
|
|
72
73
|
|
|
73
74
|
workflow_reference = find_running_workflow_reference(client, job_class, job_id)
|
|
74
75
|
raise workflow_not_found(job_id) unless workflow_reference
|
|
75
76
|
|
|
76
|
-
|
|
77
|
+
result = yield_handle(workflow_handle(client, workflow_reference), &)
|
|
78
|
+
raise workflow_not_found(job_id) if result.equal?(WORKFLOW_NOT_FOUND)
|
|
79
|
+
|
|
80
|
+
result
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def yield_schedule_execution_handle(client, job_id, &)
|
|
84
|
+
workflow_reference = JobIdValidation.schedule_execution_reference(job_id)
|
|
85
|
+
return WORKFLOW_NOT_FOUND unless workflow_reference
|
|
86
|
+
|
|
87
|
+
yield_handle(workflow_handle(client, workflow_reference), &)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def yield_handle(handle, &block)
|
|
91
|
+
block.call(handle)
|
|
92
|
+
rescue Temporalio::Error::RPCError => e
|
|
93
|
+
raise unless rpc_not_found?(e)
|
|
94
|
+
|
|
95
|
+
WORKFLOW_NOT_FOUND
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def workflow_handle(client, workflow_reference)
|
|
99
|
+
client.workflow_handle(
|
|
77
100
|
workflow_reference.fetch(:workflow_id),
|
|
78
101
|
run_id: workflow_reference[:run_id]
|
|
79
102
|
)
|
|
80
|
-
begin
|
|
81
|
-
yield(fallback_handle)
|
|
82
|
-
rescue Temporalio::Error::RPCError => e
|
|
83
|
-
raise workflow_not_found(job_id) if rpc_not_found?(e)
|
|
84
|
-
|
|
85
|
-
raise
|
|
86
|
-
end
|
|
87
103
|
end
|
|
88
104
|
|
|
89
105
|
def query_workflow(handle, handler_name, args, reject_condition)
|
|
@@ -119,11 +135,7 @@ module ActiveJob
|
|
|
119
135
|
end
|
|
120
136
|
|
|
121
137
|
def validate_job_id!(job_id)
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
raise ArgumentError,
|
|
125
|
-
"Invalid job_id format: expected UUID (e.g., '550e8400-e29b-41d4-a716-446655440000'), " \
|
|
126
|
-
"got: #{job_id.inspect}"
|
|
138
|
+
JobIdValidation.validate!(job_id)
|
|
127
139
|
end
|
|
128
140
|
|
|
129
141
|
def normalize_handler_name!(name, handler_type)
|
|
@@ -149,11 +161,11 @@ module ActiveJob
|
|
|
149
161
|
end
|
|
150
162
|
|
|
151
163
|
def rpc_not_found?(error)
|
|
152
|
-
error.code == Temporalio::Error::RPCError::Code::NOT_FOUND
|
|
164
|
+
error.respond_to?(:code) && error.code == Temporalio::Error::RPCError::Code::NOT_FOUND
|
|
153
165
|
end
|
|
154
166
|
|
|
155
167
|
def rpc_invalid_argument?(error)
|
|
156
|
-
error.code == Temporalio::Error::RPCError::Code::INVALID_ARGUMENT
|
|
168
|
+
error.respond_to?(:code) && error.code == Temporalio::Error::RPCError::Code::INVALID_ARGUMENT
|
|
157
169
|
end
|
|
158
170
|
end
|
|
159
171
|
end
|
|
@@ -11,6 +11,21 @@ module ActiveJob
|
|
|
11
11
|
# temporal_options start_to_close_timeout: 30.seconds
|
|
12
12
|
# end
|
|
13
13
|
#
|
|
14
|
+
# @example ApplicationJob defaults inherited by subclasses
|
|
15
|
+
# class ApplicationJob < ActiveJob::Base
|
|
16
|
+
# temporal_options start_to_close_timeout: 2.minutes
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# class QuickJob < ApplicationJob
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# @example Merge inherited defaults intentionally
|
|
23
|
+
# class DataProcessingJob < ApplicationJob
|
|
24
|
+
# temporal_options ApplicationJob.temporal_options.merge(
|
|
25
|
+
# heartbeat_timeout: 30.seconds
|
|
26
|
+
# )
|
|
27
|
+
# end
|
|
28
|
+
#
|
|
14
29
|
# @example Long-running job with heartbeat
|
|
15
30
|
# class DataProcessingJob < ApplicationJob
|
|
16
31
|
# temporal_options(
|
|
@@ -59,16 +74,26 @@ module ActiveJob
|
|
|
59
74
|
#
|
|
60
75
|
# @note Timeout values can be specified as either integers (seconds) or
|
|
61
76
|
# ActiveSupport::Duration objects (e.g., 2.hours, 30.seconds)
|
|
77
|
+
#
|
|
78
|
+
# @note Subclasses inherit parent options unless they declare their own.
|
|
79
|
+
# A subclass declaration replaces the inherited hash. Use +merge+ to
|
|
80
|
+
# keep inherited keys while overriding selected values.
|
|
62
81
|
def temporal_options(options = nil)
|
|
63
82
|
if options
|
|
64
83
|
validate_timeout_keys!(options)
|
|
65
84
|
@temporal_options = normalize_timeout_values(options)
|
|
66
85
|
end
|
|
67
|
-
@temporal_options || {}
|
|
86
|
+
@temporal_options || inherited_temporal_options || {}
|
|
68
87
|
end
|
|
69
88
|
|
|
70
89
|
private
|
|
71
90
|
|
|
91
|
+
def inherited_temporal_options
|
|
92
|
+
return unless superclass.respond_to?(:temporal_options)
|
|
93
|
+
|
|
94
|
+
superclass.temporal_options
|
|
95
|
+
end
|
|
96
|
+
|
|
72
97
|
# Validates that only recognized timeout keys are provided
|
|
73
98
|
#
|
|
74
99
|
# @param options [Hash] The options hash to validate
|
|
@@ -4,42 +4,42 @@ module ActiveJob
|
|
|
4
4
|
module Temporal
|
|
5
5
|
module TLSFile
|
|
6
6
|
class Error < StandardError; end
|
|
7
|
+
# Symlinks are resolved before opening, so NOFOLLOW only trips when the
|
|
8
|
+
# resolved path is swapped for a symlink between resolution and open.
|
|
7
9
|
OPEN_FLAGS = File::RDONLY | (File.const_defined?(:NOFOLLOW) ? File::NOFOLLOW : 0)
|
|
8
|
-
USES_NOFOLLOW = File.const_defined?(:NOFOLLOW)
|
|
9
10
|
|
|
10
11
|
module_function
|
|
11
12
|
|
|
13
|
+
# @param path [String, nil] path to a TLS file, symlinks allowed
|
|
14
|
+
# @return [Boolean] true when the path resolves to a readable regular file
|
|
12
15
|
def readable_regular_file?(path)
|
|
13
|
-
|
|
14
|
-
stat = File.lstat(expanded_path)
|
|
15
|
-
return false if stat.symlink?
|
|
16
|
+
resolved_path = File.realpath(File.expand_path(path))
|
|
16
17
|
|
|
17
|
-
stat.file? && File.readable?(
|
|
18
|
-
rescue Errno::ENOENT, Errno::ENOTDIR, Errno::EACCES
|
|
18
|
+
File.stat(resolved_path).file? && File.readable?(resolved_path)
|
|
19
|
+
rescue Errno::ENOENT, Errno::ENOTDIR, Errno::EACCES, Errno::ELOOP
|
|
19
20
|
false
|
|
20
21
|
end
|
|
21
22
|
|
|
23
|
+
# Reads a TLS file, resolving symlinks first so Kubernetes secret mounts
|
|
24
|
+
# (path -> ..data/path -> timestamped directory) work.
|
|
25
|
+
#
|
|
26
|
+
# @param path [String, nil] path to a TLS file
|
|
27
|
+
# @return [String, nil] file contents, or nil when path is blank
|
|
28
|
+
# @raise [Error] when the path does not resolve to a readable regular file
|
|
22
29
|
def read(path)
|
|
23
30
|
return nil if path.nil? || path.to_s.empty?
|
|
24
31
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
File.open(expanded_path, OPEN_FLAGS) do |file|
|
|
32
|
+
resolved_path = File.realpath(File.expand_path(path))
|
|
33
|
+
File.open(resolved_path, OPEN_FLAGS) do |file|
|
|
28
34
|
raise Error, "TLS file path must point to a regular file: #{path}" unless file.stat.file?
|
|
29
35
|
|
|
30
36
|
file.read
|
|
31
37
|
end
|
|
32
38
|
rescue Errno::ELOOP
|
|
33
|
-
raise Error, "TLS file path
|
|
39
|
+
raise Error, "TLS file path could not be resolved: #{path}"
|
|
34
40
|
rescue Errno::ENOENT, Errno::ENOTDIR, Errno::EACCES
|
|
35
41
|
raise Error, "TLS file path is not readable: #{path}"
|
|
36
42
|
end
|
|
37
|
-
|
|
38
|
-
def reject_symlink!(path)
|
|
39
|
-
return unless File.lstat(path).symlink?
|
|
40
|
-
|
|
41
|
-
raise Error, "TLS file path must not be a symlink: #{path}"
|
|
42
|
-
end
|
|
43
43
|
end
|
|
44
44
|
end
|
|
45
45
|
end
|
|
@@ -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,16 @@
|
|
|
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"
|
|
@@ -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)
|