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
|
@@ -38,7 +38,10 @@ module ActiveJob
|
|
|
38
38
|
# job_class: "MyJob",
|
|
39
39
|
# job_id: "abc-123",
|
|
40
40
|
# queue_name: "default",
|
|
41
|
-
#
|
|
41
|
+
# active_job: {
|
|
42
|
+
# "arguments" => [{"_aj_serialized"=>"ActiveJob::Serializers::ObjectSerializer", ...}],
|
|
43
|
+
# ...
|
|
44
|
+
# },
|
|
42
45
|
# executions: 0,
|
|
43
46
|
# exception_executions: {},
|
|
44
47
|
# scheduled_at: "2025-10-29T12:00:00Z" # optional
|
|
@@ -83,7 +86,7 @@ module ActiveJob
|
|
|
83
86
|
# - :job_class [String] Fully-qualified job class name
|
|
84
87
|
# - :job_id [String] Unique job identifier
|
|
85
88
|
# - :queue_name [String] Target queue name
|
|
86
|
-
# - :
|
|
89
|
+
# - :active_job [Hash] ActiveJob serialized payload, including arguments
|
|
87
90
|
# - :executions [Integer] Current execution count (default 0)
|
|
88
91
|
# - :exception_executions [Hash] Exception execution counts (default {})
|
|
89
92
|
# - :scheduled_at [String] ISO8601 timestamp (optional)
|
|
@@ -98,7 +101,7 @@ module ActiveJob
|
|
|
98
101
|
# @example Basic job payload
|
|
99
102
|
# job = MyJob.new
|
|
100
103
|
# payload = Payload.from_job(job)
|
|
101
|
-
# # => { job_class: "MyJob", job_id: "...",
|
|
104
|
+
# # => { job_class: "MyJob", job_id: "...", active_job: { "arguments" => [...] }, ... }
|
|
102
105
|
#
|
|
103
106
|
# @example Scheduled job payload
|
|
104
107
|
# job = MyJob.new
|
|
@@ -155,19 +158,25 @@ module ActiveJob
|
|
|
155
158
|
final_payload = serializer_for(config).dump(payload)
|
|
156
159
|
final_payload[:scheduled_at] = scheduled_timestamp if scheduled_timestamp
|
|
157
160
|
final_payload = encrypt_payload_for_transport(final_payload, encrypt, config, encryption_context)
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
+
finalize_transport_payload(
|
|
162
|
+
final_payload,
|
|
163
|
+
metrics_payload: payload,
|
|
164
|
+
config: config,
|
|
165
|
+
offload: offload ? storage_metadata || {} : nil,
|
|
166
|
+
enforce_size: enforce_size
|
|
167
|
+
)
|
|
161
168
|
end
|
|
162
169
|
|
|
163
170
|
# Deserializes job arguments from a payload hash.
|
|
164
171
|
#
|
|
165
|
-
# Extracts the serialized arguments array from
|
|
166
|
-
#
|
|
172
|
+
# Extracts the serialized arguments array from ActiveJob's canonical
|
|
173
|
+
# serialized job data, falling back to legacy top-level arguments, then
|
|
174
|
+
# uses ActiveJob's built-in deserialization to reconstruct Ruby objects
|
|
167
175
|
# (including GlobalID references to ActiveRecord models).
|
|
168
176
|
#
|
|
169
|
-
# @param payload [Hash] Payload hash containing serialized
|
|
170
|
-
# @option payload [
|
|
177
|
+
# @param payload [Hash] Payload hash containing serialized ActiveJob data
|
|
178
|
+
# @option payload [Hash] :active_job Full ActiveJob serialized payload
|
|
179
|
+
# @option payload [Array] :arguments Legacy serialized arguments
|
|
171
180
|
#
|
|
172
181
|
# @return [Array] Deserialized arguments array ready for job.perform(*args)
|
|
173
182
|
#
|
|
@@ -175,13 +184,13 @@ module ActiveJob
|
|
|
175
184
|
# @raise [GlobalID::RecordNotFound] if a GlobalID reference points to a deleted record
|
|
176
185
|
#
|
|
177
186
|
# @example Deserialize arguments
|
|
178
|
-
# payload = {
|
|
187
|
+
# payload = { active_job: { "arguments" => [{"_aj_serialized"=>"..."}] } }
|
|
179
188
|
# args = Payload.deserialize_args(payload)
|
|
180
189
|
# # => [actual_ruby_object]
|
|
181
190
|
#
|
|
182
191
|
# @example GlobalID deserialization with deleted record
|
|
183
192
|
# begin
|
|
184
|
-
# payload = {
|
|
193
|
+
# payload = { active_job: { "arguments" => [{"_aj_globalid"=>"gid://app/User/999"}] } }
|
|
185
194
|
# args = Payload.deserialize_args(payload)
|
|
186
195
|
# rescue ActiveRecord::RecordNotFound => e
|
|
187
196
|
# # Record was deleted between enqueue and execution
|
|
@@ -192,7 +201,7 @@ module ActiveJob
|
|
|
192
201
|
end
|
|
193
202
|
|
|
194
203
|
def deserialize_payload_args(payload)
|
|
195
|
-
serialized_args = payload[:arguments] || payload["arguments"]
|
|
204
|
+
serialized_args = active_job_arguments(payload) || payload[:arguments] || payload["arguments"]
|
|
196
205
|
ActiveJob::Arguments.deserialize(serialized_args)
|
|
197
206
|
rescue ActiveJob::SerializationError, ActiveJob::Temporal::ConfigurationError
|
|
198
207
|
raise
|
|
@@ -216,12 +225,13 @@ module ActiveJob
|
|
|
216
225
|
encrypt_payload_if_configured(payload, config, encryption_context: encryption_context)
|
|
217
226
|
end
|
|
218
227
|
|
|
219
|
-
def offload_payload(payload, metadata:, config: ActiveJob::Temporal.config)
|
|
228
|
+
def offload_payload(payload, metadata:, config: ActiveJob::Temporal.config, byte_size: nil)
|
|
220
229
|
PayloadStorage.offload_if_needed(
|
|
221
230
|
payload,
|
|
222
231
|
config: config,
|
|
223
232
|
metadata: metadata,
|
|
224
|
-
workflow_control_fields: WORKFLOW_CONTROL_FIELDS
|
|
233
|
+
workflow_control_fields: WORKFLOW_CONTROL_FIELDS,
|
|
234
|
+
byte_size: byte_size
|
|
225
235
|
)
|
|
226
236
|
end
|
|
227
237
|
|
|
@@ -229,19 +239,19 @@ module ActiveJob
|
|
|
229
239
|
PayloadStorage.delete(payload, config: config)
|
|
230
240
|
end
|
|
231
241
|
|
|
232
|
-
def enforce_size!(payload, metrics_payload: payload, config: ActiveJob::Temporal.config)
|
|
233
|
-
|
|
242
|
+
def enforce_size!(payload, metrics_payload: payload, config: ActiveJob::Temporal.config, byte_size: nil)
|
|
243
|
+
bytes = byte_size || JSON.generate(payload).bytesize
|
|
234
244
|
max_size_kb = config.max_payload_size_kb || 250
|
|
235
245
|
size_limit_bytes = max_size_kb * 1024
|
|
236
|
-
actual_size_kb =
|
|
237
|
-
usage_ratio =
|
|
246
|
+
actual_size_kb = bytes / 1024.0
|
|
247
|
+
usage_ratio = bytes.to_f / size_limit_bytes
|
|
238
248
|
|
|
239
249
|
Observability.emit(
|
|
240
250
|
:payload_serialize,
|
|
241
|
-
Observability.attributes_from_payload(metrics_payload, bytes:
|
|
251
|
+
Observability.attributes_from_payload(metrics_payload, bytes: bytes)
|
|
242
252
|
)
|
|
243
253
|
log_payload_size(metrics_payload, actual_size_kb, max_size_kb, usage_ratio)
|
|
244
|
-
return if
|
|
254
|
+
return if bytes <= size_limit_bytes
|
|
245
255
|
|
|
246
256
|
message = format(
|
|
247
257
|
"Job payload size (%<actual>.1f KB) exceeds maximum allowed size (%<max>d KB). " \
|
|
@@ -262,8 +272,18 @@ module ActiveJob
|
|
|
262
272
|
encrypt_payload(payload, config: config, encryption_context: encryption_context)
|
|
263
273
|
end
|
|
264
274
|
|
|
265
|
-
|
|
266
|
-
|
|
275
|
+
# One JSON pass feeds both the storage threshold check and the size limit check.
|
|
276
|
+
def finalize_transport_payload(payload, metrics_payload:, config:, offload:, enforce_size:)
|
|
277
|
+
return payload unless offload || enforce_size
|
|
278
|
+
|
|
279
|
+
byte_size = JSON.generate(payload).bytesize
|
|
280
|
+
if offload
|
|
281
|
+
offloaded = offload_payload(payload, metadata: offload, config: config, byte_size: byte_size)
|
|
282
|
+
byte_size = nil unless offloaded.equal?(payload)
|
|
283
|
+
payload = offloaded
|
|
284
|
+
end
|
|
285
|
+
enforce_size!(payload, metrics_payload: metrics_payload, config: config, byte_size: byte_size) if enforce_size
|
|
286
|
+
payload
|
|
267
287
|
end
|
|
268
288
|
|
|
269
289
|
def decrypt_transport_payload(payload, config, encryption_context)
|
|
@@ -296,12 +316,24 @@ module ActiveJob
|
|
|
296
316
|
PayloadSerializers.fetch(config.payload_serializer)
|
|
297
317
|
end
|
|
298
318
|
|
|
299
|
-
def serializer_for_transport_payload(payload,
|
|
319
|
+
def serializer_for_transport_payload(payload, config)
|
|
300
320
|
payload_serializer = payload_serializer_name(payload)
|
|
301
321
|
validate_payload_serializer_version!(payload) if payload_serializer_metadata?(payload)
|
|
322
|
+
validate_payload_serializer_allowed!(payload_serializer, config)
|
|
302
323
|
PayloadSerializers.fetch(payload_serializer)
|
|
303
324
|
end
|
|
304
325
|
|
|
326
|
+
# The payload names its own serializer, so an attacker-supplied workflow input could
|
|
327
|
+
# otherwise select Marshal (arbitrary code execution) regardless of configuration.
|
|
328
|
+
def validate_payload_serializer_allowed!(payload_serializer, config)
|
|
329
|
+
return if PayloadSerializers::DATA_ONLY.include?(payload_serializer)
|
|
330
|
+
return if payload_serializer == PayloadSerializers.normalize_name(config.payload_serializer)
|
|
331
|
+
|
|
332
|
+
raise ActiveJob::SerializationError,
|
|
333
|
+
"Payload serializer #{payload_serializer.inspect} is not permitted by the configured " \
|
|
334
|
+
"payload_serializer (#{config.payload_serializer.inspect})"
|
|
335
|
+
end
|
|
336
|
+
|
|
305
337
|
def payload_serializer_name(payload)
|
|
306
338
|
serializer_name = payload[:payload_serializer] || payload["payload_serializer"]
|
|
307
339
|
return PayloadSerializers::JSON unless serializer_name
|
|
@@ -327,6 +359,13 @@ module ActiveJob
|
|
|
327
359
|
end
|
|
328
360
|
end
|
|
329
361
|
|
|
362
|
+
def active_job_arguments(payload)
|
|
363
|
+
active_job_payload = payload[:active_job] || payload["active_job"]
|
|
364
|
+
return unless active_job_payload.respond_to?(:[])
|
|
365
|
+
|
|
366
|
+
active_job_payload[:arguments] || active_job_payload["arguments"]
|
|
367
|
+
end
|
|
368
|
+
|
|
330
369
|
# Serializes job arguments using ActiveJob's built-in serializer.
|
|
331
370
|
# @api private
|
|
332
371
|
def serialize_arguments(arguments)
|
|
@@ -352,12 +391,12 @@ module ActiveJob
|
|
|
352
391
|
job_class: job.class.name,
|
|
353
392
|
job_id: job.job_id,
|
|
354
393
|
queue_name: job.queue_name,
|
|
355
|
-
arguments: serialize_arguments(job.arguments || []),
|
|
356
394
|
executions: job.executions || 0,
|
|
357
395
|
exception_executions: job.exception_executions || {}
|
|
358
396
|
}
|
|
359
397
|
active_job_payload = serialized_active_job(job)
|
|
360
398
|
payload[:active_job] = active_job_payload if active_job_payload
|
|
399
|
+
payload[:arguments] = serialize_arguments(job.arguments || []) unless active_job_payload
|
|
361
400
|
payload
|
|
362
401
|
end
|
|
363
402
|
|
|
@@ -32,7 +32,15 @@ module ActiveJob
|
|
|
32
32
|
|
|
33
33
|
def decrypt(payload, config, context: nil)
|
|
34
34
|
version = payload[:encrypted_payload_version] || payload["encrypted_payload_version"]
|
|
35
|
-
|
|
35
|
+
if version == LEGACY_VERSION
|
|
36
|
+
unless config.allow_legacy_encrypted_payloads
|
|
37
|
+
raise ActiveJob::SerializationError,
|
|
38
|
+
"Version 1 encrypted payloads are rejected; set allow_legacy_encrypted_payloads " \
|
|
39
|
+
"to accept payloads enqueued before context-bound encryption"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
return decrypt_legacy(payload, config)
|
|
43
|
+
end
|
|
36
44
|
return decrypt_v2(payload, config, context) if version == VERSION
|
|
37
45
|
|
|
38
46
|
raise ActiveJob::SerializationError, "Unsupported encrypted payload version: #{version.inspect}"
|
|
@@ -15,6 +15,9 @@ module ActiveJob
|
|
|
15
15
|
MESSAGE_PACK_ALIAS = :msgpack
|
|
16
16
|
MARSHAL = :marshal
|
|
17
17
|
SUPPORTED = [JSON, MESSAGE_PACK, MESSAGE_PACK_ALIAS, MARSHAL].freeze
|
|
18
|
+
# Serializers that cannot instantiate arbitrary objects, so a payload may select them
|
|
19
|
+
# even when the configuration names a different serializer.
|
|
20
|
+
DATA_ONLY = [JSON, MESSAGE_PACK].freeze
|
|
18
21
|
|
|
19
22
|
def fetch(name)
|
|
20
23
|
case normalize_name(name)
|
|
@@ -16,9 +16,9 @@ module ActiveJob
|
|
|
16
16
|
payload[:external_payload] == true || payload["external_payload"] == true
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
-
def offload_if_needed(payload, config:, metadata:, workflow_control_fields:)
|
|
19
|
+
def offload_if_needed(payload, config:, metadata:, workflow_control_fields:, byte_size: nil)
|
|
20
20
|
return payload unless configured?(config)
|
|
21
|
-
return payload unless payload_exceeds_threshold?(payload, config)
|
|
21
|
+
return payload unless payload_exceeds_threshold?(payload, config, byte_size)
|
|
22
22
|
|
|
23
23
|
reference = dump_payload(payload, config, metadata)
|
|
24
24
|
envelope = {
|
|
@@ -62,8 +62,8 @@ module ActiveJob
|
|
|
62
62
|
!config.payload_storage_adapter.nil? && !config.payload_storage_threshold_kb.nil?
|
|
63
63
|
end
|
|
64
64
|
|
|
65
|
-
def payload_exceeds_threshold?(payload, config)
|
|
66
|
-
JSON.generate(payload).bytesize > (config.payload_storage_threshold_kb * 1024)
|
|
65
|
+
def payload_exceeds_threshold?(payload, config, byte_size = nil)
|
|
66
|
+
(byte_size || JSON.generate(payload).bytesize) > (config.payload_storage_threshold_kb * 1024)
|
|
67
67
|
end
|
|
68
68
|
|
|
69
69
|
def dump_payload(payload, config, metadata)
|
|
@@ -129,14 +129,7 @@ module ActiveJob
|
|
|
129
129
|
return unless Object.const_defined?(:Rails)
|
|
130
130
|
|
|
131
131
|
rails = Object.const_get(:Rails)
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
environment = rails.env
|
|
135
|
-
return unless environment.respond_to?(:development?) && environment.respond_to?(:test?)
|
|
136
|
-
return unless environment.development? || environment.test?
|
|
137
|
-
|
|
138
|
-
application = rails.application
|
|
139
|
-
application.eager_load! if application.respond_to?(:eager_load!)
|
|
132
|
+
rails.application.eager_load! if rails.env.development? || rails.env.test?
|
|
140
133
|
end
|
|
141
134
|
end
|
|
142
135
|
end
|
|
@@ -2,38 +2,38 @@
|
|
|
2
2
|
|
|
3
3
|
module ActiveJob
|
|
4
4
|
module Temporal
|
|
5
|
+
# Single-slot signal handoff between a trap handler and the reload thread.
|
|
6
|
+
#
|
|
7
|
+
# Backed by Thread::Queue because trap context forbids taking a Mutex.
|
|
5
8
|
class ReloadSignalQueue
|
|
6
|
-
POLL_INTERVAL_SECONDS = 0.05
|
|
7
|
-
|
|
8
9
|
def initialize
|
|
9
|
-
@
|
|
10
|
-
@closed = false
|
|
10
|
+
@queue = Thread::Queue.new
|
|
11
11
|
end
|
|
12
12
|
|
|
13
|
+
# Enqueues a reload signal unless one is already pending or the queue is closed.
|
|
14
|
+
#
|
|
15
|
+
# @param signal [String] Signal name
|
|
16
|
+
# @return [String, nil] The signal when enqueued, nil when coalesced or closed
|
|
13
17
|
def push(signal)
|
|
14
|
-
return if @closed ||
|
|
18
|
+
return nil if @queue.closed? || !@queue.empty?
|
|
15
19
|
|
|
16
|
-
@
|
|
20
|
+
@queue << signal
|
|
17
21
|
signal
|
|
22
|
+
rescue ClosedQueueError
|
|
23
|
+
nil
|
|
18
24
|
end
|
|
19
25
|
|
|
26
|
+
# Blocks until a signal is pending or the queue is closed.
|
|
27
|
+
#
|
|
28
|
+
# @return [String, nil] The pending signal, or nil once closed
|
|
20
29
|
def pop
|
|
21
|
-
|
|
22
|
-
return nil if @closed
|
|
23
|
-
|
|
24
|
-
if @pending_signal
|
|
25
|
-
signal = @pending_signal
|
|
26
|
-
@pending_signal = nil
|
|
27
|
-
return signal
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
sleep(POLL_INTERVAL_SECONDS)
|
|
31
|
-
end
|
|
30
|
+
@queue.pop
|
|
32
31
|
end
|
|
33
32
|
|
|
33
|
+
# @return [void]
|
|
34
34
|
def close
|
|
35
|
-
@
|
|
36
|
-
@
|
|
35
|
+
@queue.clear
|
|
36
|
+
@queue.close
|
|
37
37
|
end
|
|
38
38
|
end
|
|
39
39
|
end
|
|
@@ -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
|