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
data/api/job_payload_schema.json
CHANGED
|
@@ -71,6 +71,15 @@
|
|
|
71
71
|
"workflow_id_prefix": { "type": "string", "minLength": 1 }
|
|
72
72
|
}
|
|
73
73
|
},
|
|
74
|
+
"payload_encryption_context": {
|
|
75
|
+
"type": "object",
|
|
76
|
+
"additionalProperties": false,
|
|
77
|
+
"required": ["namespace", "workflow_id"],
|
|
78
|
+
"properties": {
|
|
79
|
+
"namespace": { "type": "string", "minLength": 1 },
|
|
80
|
+
"workflow_id": { "type": "string", "minLength": 1 }
|
|
81
|
+
}
|
|
82
|
+
},
|
|
74
83
|
"external_temporal_options": {
|
|
75
84
|
"type": "object",
|
|
76
85
|
"required": ["task_queue"],
|
|
@@ -199,19 +208,35 @@
|
|
|
199
208
|
"properties": {
|
|
200
209
|
"job_class": { "type": "string", "minLength": 1 },
|
|
201
210
|
"job_id": { "type": "string", "minLength": 1 },
|
|
202
|
-
"workflow_id": { "type": "string", "minLength": 1 }
|
|
211
|
+
"workflow_id": { "type": "string", "minLength": 1 },
|
|
212
|
+
"run_id": { "type": "string", "minLength": 1 }
|
|
203
213
|
}
|
|
204
214
|
},
|
|
205
215
|
"dependencies": {
|
|
206
216
|
"type": "array",
|
|
207
217
|
"minItems": 1,
|
|
208
218
|
"items": { "$ref": "#/definitions/dependency" }
|
|
219
|
+
},
|
|
220
|
+
"dependency_wait": {
|
|
221
|
+
"type": "object",
|
|
222
|
+
"additionalProperties": false,
|
|
223
|
+
"required": ["timeout", "initial_interval", "max_interval", "backoff"],
|
|
224
|
+
"properties": {
|
|
225
|
+
"timeout": { "type": "number", "exclusiveMinimum": 0 },
|
|
226
|
+
"initial_interval": { "type": "number", "exclusiveMinimum": 0 },
|
|
227
|
+
"max_interval": { "type": "number", "exclusiveMinimum": 0 },
|
|
228
|
+
"backoff": { "type": "number", "minimum": 1.0 }
|
|
229
|
+
}
|
|
209
230
|
}
|
|
210
231
|
},
|
|
211
232
|
"oneOf": [
|
|
212
233
|
{
|
|
213
234
|
"additionalProperties": false,
|
|
214
|
-
"required": ["job_class", "job_id", "queue_name"
|
|
235
|
+
"required": ["job_class", "job_id", "queue_name"],
|
|
236
|
+
"anyOf": [
|
|
237
|
+
{ "required": ["active_job"] },
|
|
238
|
+
{ "required": ["arguments"] }
|
|
239
|
+
],
|
|
215
240
|
"properties": {
|
|
216
241
|
"job_class": { "type": "string" },
|
|
217
242
|
"job_id": { "type": "string" },
|
|
@@ -235,7 +260,12 @@
|
|
|
235
260
|
"chain": { "$ref": "#/definitions/chain" },
|
|
236
261
|
"dependencies": { "$ref": "#/definitions/dependencies" },
|
|
237
262
|
"dependency_failure_policy": { "type": "string", "enum": ["fail", "ignore"] },
|
|
238
|
-
"
|
|
263
|
+
"dependency_wait": { "$ref": "#/definitions/dependency_wait" },
|
|
264
|
+
"activity_task_queue": { "type": "string", "minLength": 1 },
|
|
265
|
+
"schedule_id": { "type": "string", "minLength": 1 },
|
|
266
|
+
"schedule_workflow_id_prefix": { "type": "string", "minLength": 1 },
|
|
267
|
+
"schedule_execution_job_id": { "type": "string", "minLength": 1 },
|
|
268
|
+
"payload_encryption_context": { "$ref": "#/definitions/payload_encryption_context" }
|
|
239
269
|
}
|
|
240
270
|
},
|
|
241
271
|
{
|
|
@@ -258,7 +288,12 @@
|
|
|
258
288
|
"chain": { "$ref": "#/definitions/chain" },
|
|
259
289
|
"dependencies": { "$ref": "#/definitions/dependencies" },
|
|
260
290
|
"dependency_failure_policy": { "type": "string", "enum": ["fail", "ignore"] },
|
|
261
|
-
"
|
|
291
|
+
"dependency_wait": { "$ref": "#/definitions/dependency_wait" },
|
|
292
|
+
"activity_task_queue": { "type": "string", "minLength": 1 },
|
|
293
|
+
"schedule_id": { "type": "string", "minLength": 1 },
|
|
294
|
+
"schedule_workflow_id_prefix": { "type": "string", "minLength": 1 },
|
|
295
|
+
"schedule_execution_job_id": { "type": "string", "minLength": 1 },
|
|
296
|
+
"payload_encryption_context": { "$ref": "#/definitions/payload_encryption_context" }
|
|
262
297
|
}
|
|
263
298
|
},
|
|
264
299
|
{
|
|
@@ -285,7 +320,12 @@
|
|
|
285
320
|
"chain": { "$ref": "#/definitions/chain" },
|
|
286
321
|
"dependencies": { "$ref": "#/definitions/dependencies" },
|
|
287
322
|
"dependency_failure_policy": { "type": "string", "enum": ["fail", "ignore"] },
|
|
288
|
-
"
|
|
323
|
+
"dependency_wait": { "$ref": "#/definitions/dependency_wait" },
|
|
324
|
+
"activity_task_queue": { "type": "string", "minLength": 1 },
|
|
325
|
+
"schedule_id": { "type": "string", "minLength": 1 },
|
|
326
|
+
"schedule_workflow_id_prefix": { "type": "string", "minLength": 1 },
|
|
327
|
+
"schedule_execution_job_id": { "type": "string", "minLength": 1 },
|
|
328
|
+
"payload_encryption_context": { "$ref": "#/definitions/payload_encryption_context" }
|
|
289
329
|
},
|
|
290
330
|
"dependencies": {
|
|
291
331
|
"payload_serializer": ["payload_serializer_version"],
|
|
@@ -311,7 +351,12 @@
|
|
|
311
351
|
"chain": { "$ref": "#/definitions/chain" },
|
|
312
352
|
"dependencies": { "$ref": "#/definitions/dependencies" },
|
|
313
353
|
"dependency_failure_policy": { "type": "string", "enum": ["fail", "ignore"] },
|
|
314
|
-
"
|
|
354
|
+
"dependency_wait": { "$ref": "#/definitions/dependency_wait" },
|
|
355
|
+
"activity_task_queue": { "type": "string", "minLength": 1 },
|
|
356
|
+
"schedule_id": { "type": "string", "minLength": 1 },
|
|
357
|
+
"schedule_workflow_id_prefix": { "type": "string", "minLength": 1 },
|
|
358
|
+
"schedule_execution_job_id": { "type": "string", "minLength": 1 },
|
|
359
|
+
"payload_encryption_context": { "$ref": "#/definitions/payload_encryption_context" }
|
|
315
360
|
}
|
|
316
361
|
}
|
|
317
362
|
]
|
data/bin/temporal-worker
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
-
require_relative "../lib/activejob/temporal"
|
|
4
|
+
require_relative "../lib/activejob/temporal/worker_runtime"
|
|
5
5
|
require "optparse"
|
|
6
6
|
|
|
7
7
|
begin
|
|
@@ -263,13 +263,8 @@ certificate_watcher = nil
|
|
|
263
263
|
if config.tls_cert_watch
|
|
264
264
|
certificate_watcher = ActiveJob::Temporal::CertificateWatcher.new(
|
|
265
265
|
paths: ActiveJob::Temporal::CertificateWatcher.paths_from_config(config),
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
client_reloader.reload(source: "file_watch")
|
|
269
|
-
rescue StandardError
|
|
270
|
-
# Reload failures are already logged and should not stop the watcher.
|
|
271
|
-
end
|
|
272
|
-
}
|
|
266
|
+
# Failures are logged by the reloader and retried by the watcher, so let them propagate.
|
|
267
|
+
reload_callback: -> { client_reloader.reload(source: "file_watch") }
|
|
273
268
|
).start
|
|
274
269
|
end
|
|
275
270
|
|
|
@@ -76,12 +76,24 @@ module ActiveJob
|
|
|
76
76
|
end
|
|
77
77
|
end
|
|
78
78
|
|
|
79
|
+
class DiscardRequested < StandardError
|
|
80
|
+
attr_reader :job, :original_error
|
|
81
|
+
|
|
82
|
+
def initialize(job, original_error)
|
|
83
|
+
@job = job
|
|
84
|
+
@original_error = original_error
|
|
85
|
+
super(original_error.message)
|
|
86
|
+
set_backtrace(original_error.backtrace)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
79
90
|
# Executes the job inside the Temporal activity context.
|
|
80
91
|
#
|
|
81
|
-
# @param payload [Hash] Job payload with serialized
|
|
92
|
+
# @param payload [Hash] Job payload with serialized ActiveJob data and metadata
|
|
82
93
|
# @option payload [String] :job_class Fully-qualified job class name (required)
|
|
83
94
|
# @option payload [String] :job_id Unique job identifier
|
|
84
|
-
# @option payload [
|
|
95
|
+
# @option payload [Hash] :active_job Full ActiveJob serialized payload
|
|
96
|
+
# @option payload [Array] :arguments Legacy serialized job arguments
|
|
85
97
|
# @option payload [String] :queue_name Target queue name
|
|
86
98
|
# @option payload [Integer] :executions Current execution count
|
|
87
99
|
# @option payload [Hash] :exception_executions Exception execution counts
|
|
@@ -98,7 +110,9 @@ module ActiveJob
|
|
|
98
110
|
# execute({
|
|
99
111
|
# job_class: "MyJob",
|
|
100
112
|
# job_id: "123",
|
|
101
|
-
#
|
|
113
|
+
# active_job: {
|
|
114
|
+
# "arguments" => [{ "_aj_serialized" => "ActiveJob::Serializers::ObjectSerializer", "value" => {...} }]
|
|
115
|
+
# }
|
|
102
116
|
# })
|
|
103
117
|
#
|
|
104
118
|
# @example Accessing idempotency key in job
|
|
@@ -166,8 +180,9 @@ module ActiveJob
|
|
|
166
180
|
apply_activity_retry_state(job_data, job_class)
|
|
167
181
|
job = deserialize_job(job_data)
|
|
168
182
|
intercept_active_job_retry(job)
|
|
183
|
+
intercept_active_job_discard(job)
|
|
169
184
|
|
|
170
|
-
set_idempotency_key
|
|
185
|
+
set_idempotency_key(payload)
|
|
171
186
|
perform_job(job)
|
|
172
187
|
end
|
|
173
188
|
|
|
@@ -180,7 +195,7 @@ module ActiveJob
|
|
|
180
195
|
def apply_schedule_execution_identity(payload)
|
|
181
196
|
return unless payload_value(payload, :schedule_id)
|
|
182
197
|
|
|
183
|
-
execution_job_id = payload_value(payload, :schedule_execution_job_id) ||
|
|
198
|
+
execution_job_id = payload_value(payload, :schedule_execution_job_id) || activity_workflow_execution_identity
|
|
184
199
|
return unless execution_job_id
|
|
185
200
|
|
|
186
201
|
payload[:job_id] = execution_job_id
|
|
@@ -218,6 +233,18 @@ module ActiveJob
|
|
|
218
233
|
end
|
|
219
234
|
end
|
|
220
235
|
|
|
236
|
+
def intercept_active_job_discard(job)
|
|
237
|
+
original_rescue_with_handler = job.method(:rescue_with_handler)
|
|
238
|
+
job.define_singleton_method(:rescue_with_handler) do |exception|
|
|
239
|
+
handled = original_rescue_with_handler.call(exception)
|
|
240
|
+
if handled && ActiveJob::Temporal::RetryMapper.discard_exception?(self.class, exception)
|
|
241
|
+
raise DiscardRequested.new(self, exception)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
handled
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
221
248
|
def apply_activity_retry_state(job_data, job_class)
|
|
222
249
|
previous_attempts = activity_attempt - 1
|
|
223
250
|
return if previous_attempts <= 0
|
|
@@ -256,7 +283,7 @@ module ActiveJob
|
|
|
256
283
|
side_effects.after_failure("retry_observability") do
|
|
257
284
|
record_retry_observability(retry_payload, observed_error)
|
|
258
285
|
end
|
|
259
|
-
handle_exception(job_class, error)
|
|
286
|
+
handle_exception(job_class, error, retry_payload)
|
|
260
287
|
end
|
|
261
288
|
|
|
262
289
|
def instrument_perform(payload, &)
|
|
@@ -313,13 +340,10 @@ module ActiveJob
|
|
|
313
340
|
end
|
|
314
341
|
|
|
315
342
|
# @api private
|
|
316
|
-
def set_idempotency_key
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
"unknown-workflow"
|
|
321
|
-
end
|
|
322
|
-
idempotency_key = "#{workflow_id}/runner"
|
|
343
|
+
def set_idempotency_key(payload = nil)
|
|
344
|
+
identity = payload && payload_value(payload, :schedule_execution_job_id)
|
|
345
|
+
identity ||= activity_workflow_id || "unknown-workflow"
|
|
346
|
+
idempotency_key = "#{identity}/runner"
|
|
323
347
|
Thread.current[IDEMPOTENCY_KEY] = idempotency_key
|
|
324
348
|
Fiber[IDEMPOTENCY_KEY] = idempotency_key
|
|
325
349
|
end
|
|
@@ -329,10 +353,9 @@ module ActiveJob
|
|
|
329
353
|
Fiber[IDEMPOTENCY_KEY] = nil
|
|
330
354
|
end
|
|
331
355
|
|
|
356
|
+
# The AES-GCM authenticated data must come from the activity context, never from the
|
|
357
|
+
# payload: a payload-supplied context would let a tampered envelope pick its own AAD.
|
|
332
358
|
def activity_encryption_context(payload = nil)
|
|
333
|
-
payload_context = payload && (payload[:payload_encryption_context] || payload["payload_encryption_context"])
|
|
334
|
-
return payload_context if payload_context
|
|
335
|
-
|
|
336
359
|
return unless defined?(Temporalio::Activity::Context) && Temporalio::Activity::Context.exist?
|
|
337
360
|
|
|
338
361
|
info = Temporalio::Activity::Context.current.info
|
|
@@ -341,7 +364,17 @@ module ActiveJob
|
|
|
341
364
|
else
|
|
342
365
|
ActiveJob::Temporal.config.namespace
|
|
343
366
|
end
|
|
344
|
-
{ namespace: namespace, workflow_id:
|
|
367
|
+
{ namespace: namespace, workflow_id: encryption_context_workflow_id(payload) }
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# Scheduled payloads are encrypted once against the schedule's workflow-ID prefix, while
|
|
371
|
+
# each occurrence runs under a longer workflow ID derived from that prefix.
|
|
372
|
+
def encryption_context_workflow_id(payload)
|
|
373
|
+
workflow_id = activity_workflow_id
|
|
374
|
+
prefix = payload && payload_value(payload, :schedule_workflow_id_prefix)
|
|
375
|
+
return workflow_id unless prefix && workflow_id.to_s.start_with?(prefix.to_s)
|
|
376
|
+
|
|
377
|
+
prefix
|
|
345
378
|
end
|
|
346
379
|
|
|
347
380
|
def activity_workflow_id
|
|
@@ -350,22 +383,45 @@ module ActiveJob
|
|
|
350
383
|
Temporalio::Activity::Context.current.info.workflow_id
|
|
351
384
|
end
|
|
352
385
|
|
|
386
|
+
def activity_workflow_execution_identity
|
|
387
|
+
return unless defined?(Temporalio::Activity::Context) && Temporalio::Activity::Context.exist?
|
|
388
|
+
|
|
389
|
+
info = Temporalio::Activity::Context.current.info
|
|
390
|
+
[info.workflow_id, activity_workflow_run_id(info)].compact.join(":")
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def activity_workflow_run_id(info)
|
|
394
|
+
return info.workflow_run_id if info.respond_to?(:workflow_run_id)
|
|
395
|
+
|
|
396
|
+
info.run_id if info.respond_to?(:run_id)
|
|
397
|
+
end
|
|
398
|
+
|
|
353
399
|
# Handles exceptions by checking discard_on declarations.
|
|
354
400
|
# @api private
|
|
355
|
-
def handle_exception(job_class, error)
|
|
401
|
+
def handle_exception(job_class, error, retry_payload)
|
|
356
402
|
raise retryable_application_error(error) if error.is_a?(RetryRequested)
|
|
403
|
+
raise non_retryable_application_error(error.original_error) if error.is_a?(DiscardRequested)
|
|
357
404
|
|
|
358
405
|
raise non_retryable_application_error(error) if job_class.nil? && deserialization_error?(error)
|
|
359
406
|
|
|
360
|
-
|
|
407
|
+
if job_class && retry_attempts_exhausted?(job_class, error)
|
|
408
|
+
raise error if dead_letter_payload?(retry_payload)
|
|
409
|
+
|
|
410
|
+
raise non_retryable_application_error(error)
|
|
411
|
+
end
|
|
361
412
|
|
|
362
413
|
raise non_retryable_application_error(error) if job_class && RetryMapper.discard_exception?(job_class, error)
|
|
363
414
|
|
|
364
415
|
raise error
|
|
365
416
|
end
|
|
366
417
|
|
|
418
|
+
def dead_letter_payload?(payload)
|
|
419
|
+
payload_value(payload, :dead_letter)
|
|
420
|
+
end
|
|
421
|
+
|
|
367
422
|
def observed_error_for(error)
|
|
368
423
|
return error.original_error || error if error.is_a?(RetryRequested)
|
|
424
|
+
return error.original_error || error if error.is_a?(DiscardRequested)
|
|
369
425
|
|
|
370
426
|
error
|
|
371
427
|
end
|
|
@@ -9,17 +9,18 @@ require_relative "../workflow_id_builder"
|
|
|
9
9
|
module ActiveJob
|
|
10
10
|
module Temporal
|
|
11
11
|
module Activities
|
|
12
|
+
DEPENDENCY_WORKFLOW_STATES = {
|
|
13
|
+
Temporalio::Client::WorkflowExecutionStatus::RUNNING => "running",
|
|
14
|
+
Temporalio::Client::WorkflowExecutionStatus::COMPLETED => "completed",
|
|
15
|
+
Temporalio::Client::WorkflowExecutionStatus::FAILED => "failed",
|
|
16
|
+
Temporalio::Client::WorkflowExecutionStatus::CANCELED => "canceled",
|
|
17
|
+
Temporalio::Client::WorkflowExecutionStatus::TERMINATED => "terminated",
|
|
18
|
+
Temporalio::Client::WorkflowExecutionStatus::CONTINUED_AS_NEW => "continued_as_new",
|
|
19
|
+
Temporalio::Client::WorkflowExecutionStatus::TIMED_OUT => "timed_out"
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
12
22
|
class DependencyStatusActivity < Temporalio::Activity::Definition
|
|
13
23
|
SAFE_QUERY_VALUE_PATTERN = /\A[A-Za-z0-9_.:-]+\z/
|
|
14
|
-
WORKFLOW_STATES = {
|
|
15
|
-
Temporalio::Client::WorkflowExecutionStatus::RUNNING => "running",
|
|
16
|
-
Temporalio::Client::WorkflowExecutionStatus::COMPLETED => "completed",
|
|
17
|
-
Temporalio::Client::WorkflowExecutionStatus::FAILED => "failed",
|
|
18
|
-
Temporalio::Client::WorkflowExecutionStatus::CANCELED => "canceled",
|
|
19
|
-
Temporalio::Client::WorkflowExecutionStatus::TERMINATED => "terminated",
|
|
20
|
-
Temporalio::Client::WorkflowExecutionStatus::CONTINUED_AS_NEW => "continued_as_new",
|
|
21
|
-
Temporalio::Client::WorkflowExecutionStatus::TIMED_OUT => "timed_out"
|
|
22
|
-
}.freeze
|
|
23
24
|
|
|
24
25
|
def execute(dependencies)
|
|
25
26
|
Array(dependencies).map { |dependency| status_for(normalize_dependency(dependency)) }
|
|
@@ -31,7 +32,9 @@ module ActiveJob
|
|
|
31
32
|
workflow_reference = find_workflow_reference(dependency)
|
|
32
33
|
return status_payload(dependency, "not_found") unless workflow_reference
|
|
33
34
|
|
|
34
|
-
|
|
35
|
+
description = describe_workflow(workflow_reference)
|
|
36
|
+
description = follow_continued_run(description, workflow_reference) if workflow_reference[:run_id]
|
|
37
|
+
status_from_description(dependency, description)
|
|
35
38
|
rescue StandardError => e
|
|
36
39
|
fallback_description = describe_search_attribute_workflow(dependency) if rpc_not_found?(e)
|
|
37
40
|
return status_from_description(dependency, fallback_description) if fallback_description
|
|
@@ -43,21 +46,19 @@ module ActiveJob
|
|
|
43
46
|
def status_from_description(dependency, description)
|
|
44
47
|
status_payload(
|
|
45
48
|
dependency,
|
|
46
|
-
|
|
49
|
+
DEPENDENCY_WORKFLOW_STATES.fetch(description.status, "unknown"),
|
|
47
50
|
workflow_id: description.id,
|
|
48
51
|
run_id: description.run_id
|
|
49
52
|
)
|
|
50
53
|
end
|
|
51
54
|
|
|
52
55
|
def normalize_dependency(dependency)
|
|
53
|
-
dependency.
|
|
54
|
-
normalized[key.to_s] = value
|
|
55
|
-
end
|
|
56
|
+
dependency.to_h.transform_keys(&:to_s)
|
|
56
57
|
end
|
|
57
58
|
|
|
58
59
|
def find_workflow_reference(dependency)
|
|
59
60
|
workflow_id = dependency["workflow_id"]
|
|
60
|
-
return { workflow_id: workflow_id, run_id:
|
|
61
|
+
return { workflow_id: workflow_id, run_id: dependency["run_id"] } if workflow_id
|
|
61
62
|
|
|
62
63
|
search_workflow_reference(dependency) || default_workflow_reference(dependency)
|
|
63
64
|
end
|
|
@@ -66,7 +67,7 @@ module ActiveJob
|
|
|
66
67
|
job_id = dependency["job_id"]
|
|
67
68
|
return unless job_id
|
|
68
69
|
|
|
69
|
-
workflow =
|
|
70
|
+
workflow = search_ordered_workflow(dependency)
|
|
70
71
|
return unless workflow
|
|
71
72
|
|
|
72
73
|
{
|
|
@@ -79,6 +80,14 @@ module ActiveJob
|
|
|
79
80
|
raise
|
|
80
81
|
end
|
|
81
82
|
|
|
83
|
+
def search_ordered_workflow(dependency)
|
|
84
|
+
client.list_workflows(workflow_search_query(dependency, ordered: true)).first
|
|
85
|
+
rescue StandardError => e
|
|
86
|
+
raise unless rpc_invalid_argument?(e)
|
|
87
|
+
|
|
88
|
+
client.list_workflows(workflow_search_query(dependency, ordered: false)).first
|
|
89
|
+
end
|
|
90
|
+
|
|
82
91
|
def default_workflow_reference(dependency)
|
|
83
92
|
job_class = dependency["job_class"]
|
|
84
93
|
job_id = dependency["job_id"]
|
|
@@ -92,11 +101,12 @@ module ActiveJob
|
|
|
92
101
|
}
|
|
93
102
|
end
|
|
94
103
|
|
|
95
|
-
def workflow_search_query(dependency)
|
|
104
|
+
def workflow_search_query(dependency, ordered:)
|
|
96
105
|
filters = ["ajJobId='#{safe_query_value(dependency.fetch('job_id'))}'"]
|
|
97
106
|
job_class = dependency["job_class"]
|
|
98
107
|
filters.unshift("ajClass='#{safe_query_value(job_class)}'") if job_class
|
|
99
|
-
filters.join(" AND ")
|
|
108
|
+
query = filters.join(" AND ")
|
|
109
|
+
ordered ? "#{query} ORDER BY StartTime DESC" : query
|
|
100
110
|
end
|
|
101
111
|
|
|
102
112
|
def safe_query_value(value)
|
|
@@ -114,6 +124,13 @@ module ActiveJob
|
|
|
114
124
|
).describe
|
|
115
125
|
end
|
|
116
126
|
|
|
127
|
+
def follow_continued_run(description, workflow_reference)
|
|
128
|
+
state = DEPENDENCY_WORKFLOW_STATES.fetch(description.status, "unknown")
|
|
129
|
+
return description unless state == "continued_as_new"
|
|
130
|
+
|
|
131
|
+
describe_workflow(workflow_reference.merge(run_id: nil))
|
|
132
|
+
end
|
|
133
|
+
|
|
117
134
|
def describe_search_attribute_workflow(dependency)
|
|
118
135
|
return unless dependency["workflow_id"] && dependency["job_id"]
|
|
119
136
|
|
|
@@ -134,7 +151,7 @@ module ActiveJob
|
|
|
134
151
|
"job_id" => dependency["job_id"],
|
|
135
152
|
"job_class" => dependency["job_class"],
|
|
136
153
|
"workflow_id" => workflow_id || dependency["workflow_id"],
|
|
137
|
-
"run_id" => run_id,
|
|
154
|
+
"run_id" => run_id || dependency["run_id"],
|
|
138
155
|
"state" => state
|
|
139
156
|
}.compact
|
|
140
157
|
end
|
|
@@ -103,9 +103,10 @@ module ActiveJob
|
|
|
103
103
|
#
|
|
104
104
|
# @note Transaction Safety
|
|
105
105
|
# Jobs using the Temporal adapter are opted into ActiveJob's
|
|
106
|
-
# `enqueue_after_transaction_commit` setting
|
|
107
|
-
#
|
|
108
|
-
# starting for rolled-back
|
|
106
|
+
# `enqueue_after_transaction_commit` setting unless the job class configured
|
|
107
|
+
# it explicitly. This defers workflow starts until the current database
|
|
108
|
+
# transaction commits and prevents workflows from starting for rolled-back
|
|
109
|
+
# jobs.
|
|
109
110
|
#
|
|
110
111
|
# @example Basic usage
|
|
111
112
|
# class MyJob < ApplicationJob
|
|
@@ -5,6 +5,9 @@ require_relative "batch_enqueue_result"
|
|
|
5
5
|
module ActiveJob
|
|
6
6
|
module Temporal
|
|
7
7
|
class BatchEnqueuer
|
|
8
|
+
MAX_BATCH_SIZE = 10_000
|
|
9
|
+
QUEUE_STOP = Object.new.freeze
|
|
10
|
+
|
|
8
11
|
def initialize(enqueue:, validate_job:, validate_scheduled_at:)
|
|
9
12
|
@enqueue_job = enqueue
|
|
10
13
|
@validate_job = validate_job
|
|
@@ -12,8 +15,8 @@ module ActiveJob
|
|
|
12
15
|
end
|
|
13
16
|
|
|
14
17
|
def enqueue(items, concurrency: 1)
|
|
15
|
-
entries = validate_entries!(items)
|
|
16
18
|
concurrency = validate_concurrency!(concurrency)
|
|
19
|
+
entries = validate_entries!(items)
|
|
17
20
|
results = Array.new(entries.length)
|
|
18
21
|
|
|
19
22
|
enqueue_entries(entries, results, concurrency)
|
|
@@ -28,9 +31,14 @@ module ActiveJob
|
|
|
28
31
|
def validate_entries!(items)
|
|
29
32
|
raise ArgumentError, "batch enqueue jobs must be an Enumerable" unless items.respond_to?(:each)
|
|
30
33
|
|
|
34
|
+
validate_batch_size_hint!(items)
|
|
35
|
+
|
|
31
36
|
errors = []
|
|
32
|
-
entries =
|
|
33
|
-
|
|
37
|
+
entries = []
|
|
38
|
+
items.each.with_index do |item, index|
|
|
39
|
+
raise_batch_size_error! if index >= MAX_BATCH_SIZE
|
|
40
|
+
|
|
41
|
+
entries << validate_entry(item, index, errors)
|
|
34
42
|
end
|
|
35
43
|
|
|
36
44
|
raise ArgumentError, "batch enqueue jobs cannot be empty" if entries.empty?
|
|
@@ -53,6 +61,20 @@ module ActiveJob
|
|
|
53
61
|
nil
|
|
54
62
|
end
|
|
55
63
|
|
|
64
|
+
def validate_batch_size_hint!(items)
|
|
65
|
+
return unless items.respond_to?(:size)
|
|
66
|
+
|
|
67
|
+
size = items.size
|
|
68
|
+
return if size.nil? || size <= MAX_BATCH_SIZE
|
|
69
|
+
|
|
70
|
+
raise_batch_size_error!
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def raise_batch_size_error!
|
|
74
|
+
raise ArgumentError,
|
|
75
|
+
"batch enqueue accepts at most #{MAX_BATCH_SIZE} jobs; split larger inputs into smaller batches"
|
|
76
|
+
end
|
|
77
|
+
|
|
56
78
|
def normalize_entry(item)
|
|
57
79
|
return { job: item, scheduled_at: nil } if active_job_instance?(item)
|
|
58
80
|
|
|
@@ -82,19 +104,23 @@ module ActiveJob
|
|
|
82
104
|
def enqueue_entries(entries, results, concurrency)
|
|
83
105
|
return enqueue_sequentially(entries, results) if concurrency == 1
|
|
84
106
|
|
|
85
|
-
entry_queue = Queue.new
|
|
86
|
-
entries.each { |entry| entry_queue << entry }
|
|
87
107
|
worker_count = [concurrency, entries.length].min
|
|
108
|
+
entry_queue = SizedQueue.new(worker_count)
|
|
88
109
|
|
|
89
|
-
Array.new(worker_count) do
|
|
110
|
+
workers = Array.new(worker_count) do
|
|
90
111
|
Thread.new do
|
|
91
112
|
loop do
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
113
|
+
entry = entry_queue.pop
|
|
114
|
+
break if entry.equal?(QUEUE_STOP)
|
|
115
|
+
|
|
116
|
+
enqueue_entry(entry, results)
|
|
95
117
|
end
|
|
96
118
|
end
|
|
97
|
-
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
entries.each { |entry| entry_queue << entry }
|
|
122
|
+
worker_count.times { entry_queue << QUEUE_STOP }
|
|
123
|
+
workers.each(&:value)
|
|
98
124
|
end
|
|
99
125
|
|
|
100
126
|
def enqueue_sequentially(entries, results)
|
|
@@ -103,36 +129,19 @@ module ActiveJob
|
|
|
103
129
|
|
|
104
130
|
def enqueue_entry(entry, results)
|
|
105
131
|
handle = enqueue_job.call(entry[:job], scheduled_at: entry[:scheduled_at])
|
|
106
|
-
results[entry[:index]] =
|
|
132
|
+
results[entry[:index]] = item_result(entry, status: :success, handle: handle)
|
|
107
133
|
rescue DuplicateEnqueueError => e
|
|
108
|
-
results[entry[:index]] =
|
|
134
|
+
results[entry[:index]] = item_result(entry, status: :duplicate, error: e)
|
|
109
135
|
rescue StandardError => e
|
|
110
|
-
results[entry[:index]] =
|
|
111
|
-
end
|
|
112
|
-
|
|
113
|
-
def success_result(entry, handle)
|
|
114
|
-
BatchEnqueueItemResult.new(
|
|
115
|
-
index: entry[:index],
|
|
116
|
-
job: entry[:job],
|
|
117
|
-
status: :success,
|
|
118
|
-
handle: handle
|
|
119
|
-
)
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
def duplicate_result(entry, error)
|
|
123
|
-
BatchEnqueueItemResult.new(
|
|
124
|
-
index: entry[:index],
|
|
125
|
-
job: entry[:job],
|
|
126
|
-
status: :duplicate,
|
|
127
|
-
error: error
|
|
128
|
-
)
|
|
136
|
+
results[entry[:index]] = item_result(entry, status: :failed, error: e)
|
|
129
137
|
end
|
|
130
138
|
|
|
131
|
-
def
|
|
139
|
+
def item_result(entry, status:, handle: nil, error: nil)
|
|
132
140
|
BatchEnqueueItemResult.new(
|
|
133
141
|
index: entry[:index],
|
|
134
142
|
job: entry[:job],
|
|
135
|
-
status:
|
|
143
|
+
status: status,
|
|
144
|
+
handle: handle,
|
|
136
145
|
error: error
|
|
137
146
|
)
|
|
138
147
|
end
|