activejob-temporal 0.1.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 +7 -0
- data/CHANGELOG.md +130 -0
- data/LICENSE +21 -0
- data/README.md +198 -0
- data/activejob-temporal.gemspec +58 -0
- data/api/job_payload_schema.json +318 -0
- data/bin/temporal-worker +295 -0
- data/lib/activejob/temporal/active_job_handler_source.rb +84 -0
- data/lib/activejob/temporal/activities/aj_runner_activity.rb +454 -0
- data/lib/activejob/temporal/activities/best_effort_side_effects.rb +49 -0
- data/lib/activejob/temporal/activities/dependency_status_activity.rb +160 -0
- data/lib/activejob/temporal/activities/rate_limit_activity.rb +41 -0
- data/lib/activejob/temporal/adapter.rb +257 -0
- data/lib/activejob/temporal/audit_log.rb +118 -0
- data/lib/activejob/temporal/batch_enqueue_result.rb +110 -0
- data/lib/activejob/temporal/batch_enqueuer.rb +141 -0
- data/lib/activejob/temporal/bind_policy.rb +44 -0
- data/lib/activejob/temporal/cancel/batch_canceller.rb +154 -0
- data/lib/activejob/temporal/cancel/batch_summary.rb +45 -0
- data/lib/activejob/temporal/cancel.rb +236 -0
- data/lib/activejob/temporal/certificate_watcher.rb +76 -0
- data/lib/activejob/temporal/chain_options.rb +83 -0
- data/lib/activejob/temporal/child_workflow_options.rb +102 -0
- data/lib/activejob/temporal/client.rb +215 -0
- data/lib/activejob/temporal/conditional_enqueue.rb +56 -0
- data/lib/activejob/temporal/configurable.rb +55 -0
- data/lib/activejob/temporal/configuration.rb +981 -0
- data/lib/activejob/temporal/configured_job_compatibility.rb +44 -0
- data/lib/activejob/temporal/connection_worker_pool.rb +88 -0
- data/lib/activejob/temporal/dead_letter_payload_validation.rb +34 -0
- data/lib/activejob/temporal/dead_letter_queue.rb +163 -0
- data/lib/activejob/temporal/dependency_options.rb +134 -0
- data/lib/activejob/temporal/external_operation.rb +193 -0
- data/lib/activejob/temporal/health_check_server.rb +159 -0
- data/lib/activejob/temporal/http_line_reader.rb +36 -0
- data/lib/activejob/temporal/inspect.rb +184 -0
- data/lib/activejob/temporal/job_descriptor.rb +37 -0
- data/lib/activejob/temporal/job_payload_builder.rb +209 -0
- data/lib/activejob/temporal/job_payload_chain_builder.rb +106 -0
- data/lib/activejob/temporal/job_payload_child_workflows.rb +127 -0
- data/lib/activejob/temporal/job_payload_dependencies.rb +40 -0
- data/lib/activejob/temporal/job_payload_rate_limits.rb +53 -0
- data/lib/activejob/temporal/job_payload_workflow_interactions.rb +31 -0
- data/lib/activejob/temporal/job_tags.rb +40 -0
- data/lib/activejob/temporal/locales/en.yml +126 -0
- data/lib/activejob/temporal/logger.rb +214 -0
- data/lib/activejob/temporal/metrics_server.rb +150 -0
- data/lib/activejob/temporal/middleware/chain.rb +106 -0
- data/lib/activejob/temporal/middleware.rb +11 -0
- data/lib/activejob/temporal/observability/datadog.rb +167 -0
- data/lib/activejob/temporal/observability/opentelemetry.rb +107 -0
- data/lib/activejob/temporal/observability/prometheus.rb +271 -0
- data/lib/activejob/temporal/observability.rb +260 -0
- data/lib/activejob/temporal/payload.rb +415 -0
- data/lib/activejob/temporal/payload_encryption.rb +215 -0
- data/lib/activejob/temporal/payload_serializers/json.rb +23 -0
- data/lib/activejob/temporal/payload_serializers/marshal.rb +53 -0
- data/lib/activejob/temporal/payload_serializers/message_pack.rb +59 -0
- data/lib/activejob/temporal/payload_serializers.rb +37 -0
- data/lib/activejob/temporal/payload_storage.rb +103 -0
- data/lib/activejob/temporal/rails_environment_loader.rb +143 -0
- data/lib/activejob/temporal/rate_limit_options.rb +94 -0
- data/lib/activejob/temporal/rate_limiters/memory.rb +198 -0
- data/lib/activejob/temporal/reload_signal_queue.rb +40 -0
- data/lib/activejob/temporal/retry_handler_extractor.rb +361 -0
- data/lib/activejob/temporal/retry_mapper.rb +264 -0
- data/lib/activejob/temporal/schedulable.rb +60 -0
- data/lib/activejob/temporal/schedule.rb +181 -0
- data/lib/activejob/temporal/schedule_options.rb +105 -0
- data/lib/activejob/temporal/search_attributes.rb +173 -0
- data/lib/activejob/temporal/signal_query.rb +161 -0
- data/lib/activejob/temporal/signal_query_options.rb +106 -0
- data/lib/activejob/temporal/temporal_options.rb +114 -0
- data/lib/activejob/temporal/tls_file.rb +45 -0
- data/lib/activejob/temporal/transaction_safety.rb +39 -0
- data/lib/activejob/temporal/version.rb +7 -0
- data/lib/activejob/temporal/visibility_query.rb +13 -0
- data/lib/activejob/temporal/worker_client_reloader.rb +34 -0
- data/lib/activejob/temporal/worker_health.rb +117 -0
- data/lib/activejob/temporal/worker_pool.rb +408 -0
- data/lib/activejob/temporal/workflow_enqueuer.rb +271 -0
- data/lib/activejob/temporal/workflow_enqueuer_batch.rb +17 -0
- data/lib/activejob/temporal/workflow_id_builder.rb +155 -0
- data/lib/activejob/temporal/workflow_identity.rb +62 -0
- data/lib/activejob/temporal/workflows/aj_workflow.rb +282 -0
- data/lib/activejob/temporal/workflows/dead_letter_support.rb +134 -0
- data/lib/activejob/temporal/workflows/dead_letter_workflow.rb +114 -0
- data/lib/activejob/temporal/workflows/workflow_chaining.rb +194 -0
- data/lib/activejob/temporal/workflows/workflow_child_workflows.rb +140 -0
- data/lib/activejob/temporal/workflows/workflow_continue_as_new.rb +44 -0
- data/lib/activejob/temporal/workflows/workflow_dependencies.rb +115 -0
- data/lib/activejob/temporal/workflows/workflow_execution_steps.rb +22 -0
- data/lib/activejob/temporal/workflows/workflow_interactions.rb +215 -0
- data/lib/activejob/temporal/workflows/workflow_local_activities.rb +29 -0
- data/lib/activejob/temporal/workflows/workflow_nexus.rb +15 -0
- data/lib/activejob/temporal/workflows/workflow_versioning.rb +21 -0
- data/lib/activejob/temporal.rb +297 -0
- data/lib/activejob-temporal.rb +3 -0
- metadata +423 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_job"
|
|
4
|
+
require_relative "logger"
|
|
5
|
+
|
|
6
|
+
module ActiveJob
|
|
7
|
+
module Temporal
|
|
8
|
+
module ConfiguredJobCompatibility
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def payload(value, feature:, normalize_options:)
|
|
12
|
+
return unless configured_job?(value)
|
|
13
|
+
|
|
14
|
+
log_private_api(feature)
|
|
15
|
+
|
|
16
|
+
job_class = value.instance_variable_get(:@job_class)
|
|
17
|
+
return unless active_job_class?(job_class)
|
|
18
|
+
|
|
19
|
+
{
|
|
20
|
+
job_class: job_class.name,
|
|
21
|
+
options: normalize_options.call(value.instance_variable_get(:@options) || {})
|
|
22
|
+
}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def configured_job?(value)
|
|
26
|
+
defined?(ActiveJob::ConfiguredJob) && value.is_a?(ActiveJob::ConfiguredJob)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def active_job_class?(job_class)
|
|
30
|
+
job_class.is_a?(Class) && job_class < ActiveJob::Base && job_class.name
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def log_private_api(feature)
|
|
34
|
+
ActiveJob::Temporal::Logger.warn(
|
|
35
|
+
"active_job_configured_job_private_api",
|
|
36
|
+
feature: feature,
|
|
37
|
+
replacement: "ActiveJob::Temporal.job"
|
|
38
|
+
)
|
|
39
|
+
rescue StandardError
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveJob
|
|
4
|
+
module Temporal
|
|
5
|
+
class ConnectionWorkerPool
|
|
6
|
+
def initialize(size:, queue_size:, name:, &handler)
|
|
7
|
+
@size = Integer(size)
|
|
8
|
+
@queue_size = Integer(queue_size)
|
|
9
|
+
@name = name
|
|
10
|
+
@handler = handler
|
|
11
|
+
@mutex = Mutex.new
|
|
12
|
+
@started = false
|
|
13
|
+
|
|
14
|
+
raise ArgumentError, "size must be positive" unless @size.positive?
|
|
15
|
+
raise ArgumentError, "queue_size must be positive" unless @queue_size.positive?
|
|
16
|
+
raise ArgumentError, "handler is required" unless @handler
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def start
|
|
20
|
+
@mutex.synchronize do
|
|
21
|
+
return self if @started
|
|
22
|
+
|
|
23
|
+
@queue = SizedQueue.new(@queue_size)
|
|
24
|
+
queue = @queue
|
|
25
|
+
@workers = Array.new(@size) do |index|
|
|
26
|
+
Thread.new(queue, index) { |worker_queue, worker_index| run_worker(worker_queue, worker_index) }
|
|
27
|
+
end
|
|
28
|
+
@started = true
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
self
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def enqueue(connection)
|
|
35
|
+
queue = @mutex.synchronize { @queue if @started }
|
|
36
|
+
unless queue
|
|
37
|
+
close_connection(connection)
|
|
38
|
+
return false
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
queue.push(connection, true)
|
|
42
|
+
true
|
|
43
|
+
rescue ClosedQueueError, ThreadError
|
|
44
|
+
close_connection(connection)
|
|
45
|
+
false
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def stop(timeout:)
|
|
49
|
+
queue = nil
|
|
50
|
+
workers = nil
|
|
51
|
+
|
|
52
|
+
@mutex.synchronize do
|
|
53
|
+
return unless @started
|
|
54
|
+
|
|
55
|
+
queue = @queue
|
|
56
|
+
workers = @workers
|
|
57
|
+
@queue = nil
|
|
58
|
+
@workers = []
|
|
59
|
+
@started = false
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
queue.close
|
|
63
|
+
workers.each { |worker| worker.join(timeout) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def run_worker(queue, index)
|
|
69
|
+
Thread.current.name = "#{@name}-#{index}" if Thread.current.respond_to?(:name=)
|
|
70
|
+
|
|
71
|
+
loop do
|
|
72
|
+
connection = queue.pop
|
|
73
|
+
break unless connection
|
|
74
|
+
|
|
75
|
+
@handler.call(connection)
|
|
76
|
+
end
|
|
77
|
+
rescue ClosedQueueError
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def close_connection(connection)
|
|
82
|
+
connection.close
|
|
83
|
+
rescue IOError, SystemCallError
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveJob
|
|
4
|
+
module Temporal
|
|
5
|
+
module DeadLetterPayloadValidation
|
|
6
|
+
extend self
|
|
7
|
+
|
|
8
|
+
def validate!(payload)
|
|
9
|
+
validate_metadata!(payload_value(payload, :dead_letter), "dead_letter.queue")
|
|
10
|
+
|
|
11
|
+
Array(payload_value(payload, :chain)).each do |chain_step|
|
|
12
|
+
validate_metadata!(payload_value(chain_step, :dead_letter), "chain.dead_letter.queue")
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def validate_metadata!(metadata, queue_path)
|
|
19
|
+
return unless metadata
|
|
20
|
+
return if payload_value(metadata, :queue).to_s.strip.present?
|
|
21
|
+
|
|
22
|
+
raise ConfigurationError, "#{queue_path} cannot be blank"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def payload_value(payload, key)
|
|
26
|
+
return unless payload.respond_to?(:[])
|
|
27
|
+
|
|
28
|
+
payload[key] || payload[key.to_s]
|
|
29
|
+
rescue TypeError
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "temporalio/client"
|
|
4
|
+
|
|
5
|
+
module ActiveJob
|
|
6
|
+
module Temporal
|
|
7
|
+
module DeadLetterQueue
|
|
8
|
+
WORKFLOW_TYPE = "ActiveJobTemporalDeadLetterWorkflow"
|
|
9
|
+
DEFAULT_ENTRIES_LIMIT = 100
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def entry(job_class, job_id, run_id: nil, client: ActiveJob::Temporal.client)
|
|
14
|
+
handle_for(job_class, job_id, run_id: run_id, client: client).query(:entry)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def entries(queue: nil, limit: DEFAULT_ENTRIES_LIMIT, client: ActiveJob::Temporal.client)
|
|
18
|
+
validate_limit!(limit)
|
|
19
|
+
|
|
20
|
+
client.list_workflows(entries_query(queue)).each_with_object([]) do |workflow, entries|
|
|
21
|
+
entry = query_workflow_entry(client, workflow)
|
|
22
|
+
entries << entry if entry
|
|
23
|
+
break entries if entries.size >= limit
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def retry(job_class, job_id, queue: nil, client: ActiveJob::Temporal.client)
|
|
28
|
+
handle = handle_for(job_class, job_id, client: client)
|
|
29
|
+
entry = handle.query(:entry)
|
|
30
|
+
return entry.fetch("retry_workflow_id") if retried_entry?(entry)
|
|
31
|
+
|
|
32
|
+
ensure_pending_entry!(entry)
|
|
33
|
+
|
|
34
|
+
workflow_id = retry_workflow_id(entry)
|
|
35
|
+
start_retry_workflow(client, entry, workflow_id, queue)
|
|
36
|
+
mark_retried_entry(handle, workflow_id)
|
|
37
|
+
workflow_id
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# rubocop:disable Naming/PredicateMethod
|
|
41
|
+
def discard(job_class, job_id, reason: nil, client: ActiveJob::Temporal.client)
|
|
42
|
+
handle_for(job_class, job_id, client: client).signal(:discard, reason)
|
|
43
|
+
true
|
|
44
|
+
end
|
|
45
|
+
# rubocop:enable Naming/PredicateMethod
|
|
46
|
+
|
|
47
|
+
def workflow_id(job_class, job_id)
|
|
48
|
+
class_name = job_class.is_a?(Class) ? job_class.name : job_class.to_s
|
|
49
|
+
"ajdlq:#{class_name}:#{job_id}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def handle_for(job_class, job_id, run_id: nil, client: ActiveJob::Temporal.client)
|
|
53
|
+
client.workflow_handle(workflow_id(job_class, job_id), run_id: run_id)
|
|
54
|
+
end
|
|
55
|
+
private_class_method :handle_for
|
|
56
|
+
|
|
57
|
+
def entries_query(queue)
|
|
58
|
+
query = ["WorkflowType='#{WORKFLOW_TYPE}'", "ExecutionStatus='Running'"]
|
|
59
|
+
query << "TaskQueue='#{escape_query_value(queue)}'" if queue.to_s.strip.present?
|
|
60
|
+
query.join(" AND ")
|
|
61
|
+
end
|
|
62
|
+
private_class_method :entries_query
|
|
63
|
+
|
|
64
|
+
def query_workflow_entry(client, workflow)
|
|
65
|
+
client.workflow_handle(workflow.id, run_id: workflow_run_id(workflow)).query(:entry)
|
|
66
|
+
rescue Temporalio::Error
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
69
|
+
private_class_method :query_workflow_entry
|
|
70
|
+
|
|
71
|
+
def start_retry_workflow(client, entry, workflow_id, queue)
|
|
72
|
+
client.start_workflow(
|
|
73
|
+
ActiveJob::Temporal::Workflows::AjWorkflow,
|
|
74
|
+
retry_payload(entry),
|
|
75
|
+
id: workflow_id,
|
|
76
|
+
task_queue: retry_task_queue(entry, queue),
|
|
77
|
+
id_conflict_policy: Temporalio::WorkflowIDConflictPolicy::FAIL
|
|
78
|
+
)
|
|
79
|
+
rescue StandardError => e
|
|
80
|
+
raise unless workflow_already_started?(e)
|
|
81
|
+
end
|
|
82
|
+
private_class_method :start_retry_workflow
|
|
83
|
+
|
|
84
|
+
def mark_retried_entry(handle, workflow_id)
|
|
85
|
+
handle.signal(:mark_retried, workflow_id)
|
|
86
|
+
rescue StandardError => e
|
|
87
|
+
return if entry_retried_with_workflow_id?(query_entry_after_mark_failure(handle), workflow_id)
|
|
88
|
+
|
|
89
|
+
message = "Retry workflow #{workflow_id} may be running, but could not mark dead letter entry retried"
|
|
90
|
+
raise ActiveJob::Temporal::Error.new(message), cause: e
|
|
91
|
+
end
|
|
92
|
+
private_class_method :mark_retried_entry
|
|
93
|
+
|
|
94
|
+
def query_entry_after_mark_failure(handle)
|
|
95
|
+
handle.query(:entry)
|
|
96
|
+
rescue StandardError
|
|
97
|
+
nil
|
|
98
|
+
end
|
|
99
|
+
private_class_method :query_entry_after_mark_failure
|
|
100
|
+
|
|
101
|
+
def ensure_pending_entry!(entry)
|
|
102
|
+
state = entry.fetch("state", "pending")
|
|
103
|
+
return if state == "pending"
|
|
104
|
+
|
|
105
|
+
message = "Cannot retry dead letter entry #{entry.fetch('id')} with state #{state.inspect}"
|
|
106
|
+
raise ActiveJob::Temporal::Error, message
|
|
107
|
+
end
|
|
108
|
+
private_class_method :ensure_pending_entry!
|
|
109
|
+
|
|
110
|
+
def retried_entry?(entry)
|
|
111
|
+
entry["state"] == "retried" && entry["retry_workflow_id"].to_s.strip.present?
|
|
112
|
+
end
|
|
113
|
+
private_class_method :retried_entry?
|
|
114
|
+
|
|
115
|
+
def entry_retried_with_workflow_id?(entry, workflow_id)
|
|
116
|
+
return false unless entry
|
|
117
|
+
|
|
118
|
+
entry["state"] == "retried" && entry["retry_workflow_id"] == workflow_id
|
|
119
|
+
end
|
|
120
|
+
private_class_method :entry_retried_with_workflow_id?
|
|
121
|
+
|
|
122
|
+
def retry_workflow_id(entry)
|
|
123
|
+
"ajdlq-retry:#{entry.fetch('id')}"
|
|
124
|
+
end
|
|
125
|
+
private_class_method :retry_workflow_id
|
|
126
|
+
|
|
127
|
+
def retry_payload(entry)
|
|
128
|
+
entry.fetch("payload").reject { |key, _value| key.to_s == "scheduled_at" }
|
|
129
|
+
end
|
|
130
|
+
private_class_method :retry_payload
|
|
131
|
+
|
|
132
|
+
def retry_task_queue(entry, queue)
|
|
133
|
+
queue || entry.dig("metadata", "original_task_queue") || entry.dig("metadata", "original_queue_name")
|
|
134
|
+
end
|
|
135
|
+
private_class_method :retry_task_queue
|
|
136
|
+
|
|
137
|
+
def workflow_run_id(workflow)
|
|
138
|
+
workflow.respond_to?(:run_id) ? workflow.run_id : nil
|
|
139
|
+
end
|
|
140
|
+
private_class_method :workflow_run_id
|
|
141
|
+
|
|
142
|
+
def validate_limit!(limit)
|
|
143
|
+
return if limit.is_a?(Integer) && limit.positive?
|
|
144
|
+
|
|
145
|
+
raise ArgumentError, "limit must be a positive integer"
|
|
146
|
+
end
|
|
147
|
+
private_class_method :validate_limit!
|
|
148
|
+
|
|
149
|
+
def escape_query_value(value)
|
|
150
|
+
value.to_s.gsub("'", "''")
|
|
151
|
+
end
|
|
152
|
+
private_class_method :escape_query_value
|
|
153
|
+
|
|
154
|
+
def workflow_already_started?(error)
|
|
155
|
+
(defined?(Temporalio::Error::WorkflowAlreadyStartedError) &&
|
|
156
|
+
error.is_a?(Temporalio::Error::WorkflowAlreadyStartedError)) ||
|
|
157
|
+
(defined?(Temporalio::Client::WorkflowAlreadyStartedError) &&
|
|
158
|
+
error.is_a?(Temporalio::Client::WorkflowAlreadyStartedError))
|
|
159
|
+
end
|
|
160
|
+
private_class_method :workflow_already_started?
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_job"
|
|
4
|
+
require_relative "workflow_id_builder"
|
|
5
|
+
|
|
6
|
+
module ActiveJob
|
|
7
|
+
module Temporal
|
|
8
|
+
module DependencyOptions
|
|
9
|
+
JOB_CLASS_NAME_PATTERN = /\A[A-Z]\w*(?:::[A-Z]\w*)*\z/
|
|
10
|
+
SAFE_ID_PATTERN = /\A[A-Za-z0-9_.:-]+\z/
|
|
11
|
+
FAILURE_POLICIES = %i[fail ignore].freeze
|
|
12
|
+
|
|
13
|
+
attr_reader :temporal_dependencies, :temporal_dependency_failure_policy
|
|
14
|
+
|
|
15
|
+
def self.normalize(depends_on)
|
|
16
|
+
dependencies = depends_on.is_a?(Array) ? depends_on : [depends_on]
|
|
17
|
+
raise ArgumentError, "depends_on must contain at least one job dependency" if dependencies.empty?
|
|
18
|
+
|
|
19
|
+
dependencies.map { |dependency| normalize_dependency(dependency) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.normalize_failure_policy(policy)
|
|
23
|
+
normalized_policy = policy.to_sym
|
|
24
|
+
return normalized_policy if FAILURE_POLICIES.include?(normalized_policy)
|
|
25
|
+
|
|
26
|
+
raise ArgumentError, "on_dependency_failure must be :fail or :ignore"
|
|
27
|
+
rescue NoMethodError
|
|
28
|
+
raise ArgumentError, "on_dependency_failure must be :fail or :ignore"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.normalize_dependency(dependency)
|
|
32
|
+
return normalize_job_dependency(dependency) if dependency.is_a?(ActiveJob::Base)
|
|
33
|
+
return normalize_hash_dependency(dependency) if dependency.is_a?(Hash)
|
|
34
|
+
return { job_id: normalize_id(dependency, "job_id") } if dependency.is_a?(String)
|
|
35
|
+
|
|
36
|
+
raise ArgumentError, "depends_on entries must be ActiveJob instances, job IDs, or dependency hashes"
|
|
37
|
+
end
|
|
38
|
+
private_class_method :normalize_dependency
|
|
39
|
+
|
|
40
|
+
def self.normalize_job_dependency(job)
|
|
41
|
+
{
|
|
42
|
+
job_class: normalize_job_class(job.class),
|
|
43
|
+
job_id: normalize_id(job.job_id, "job_id"),
|
|
44
|
+
workflow_id: WorkflowIdBuilder.new(configured_workflow_id_generator).build(job)
|
|
45
|
+
}
|
|
46
|
+
end
|
|
47
|
+
private_class_method :normalize_job_dependency
|
|
48
|
+
|
|
49
|
+
def self.configured_workflow_id_generator
|
|
50
|
+
ActiveJob::Temporal.config.workflow_id_generator if ActiveJob::Temporal.respond_to?(:config)
|
|
51
|
+
end
|
|
52
|
+
private_class_method :configured_workflow_id_generator
|
|
53
|
+
|
|
54
|
+
def self.normalize_hash_dependency(dependency)
|
|
55
|
+
normalized = {}
|
|
56
|
+
job_id = hash_value(dependency, :job_id)
|
|
57
|
+
workflow_id = hash_value(dependency, :workflow_id)
|
|
58
|
+
job_class = hash_value(dependency, :job_class)
|
|
59
|
+
|
|
60
|
+
normalized[:job_id] = normalize_id(job_id, "job_id") if job_id
|
|
61
|
+
normalized[:workflow_id] = normalize_id(workflow_id, "workflow_id") if workflow_id
|
|
62
|
+
normalized[:job_class] = normalize_job_class(job_class) if job_class
|
|
63
|
+
|
|
64
|
+
if normalized[:job_id].nil? && normalized[:workflow_id].nil?
|
|
65
|
+
raise ArgumentError, "dependency hashes must include job_id or workflow_id"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
normalized
|
|
69
|
+
end
|
|
70
|
+
private_class_method :normalize_hash_dependency
|
|
71
|
+
|
|
72
|
+
def self.normalize_job_class(job_class)
|
|
73
|
+
name = if job_class.is_a?(Class) && job_class < ActiveJob::Base
|
|
74
|
+
job_class.name
|
|
75
|
+
else
|
|
76
|
+
job_class.to_s
|
|
77
|
+
end
|
|
78
|
+
unless name.match?(JOB_CLASS_NAME_PATTERN)
|
|
79
|
+
raise ArgumentError, "dependency job_class must be a named ActiveJob class or valid class name"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
name
|
|
83
|
+
end
|
|
84
|
+
private_class_method :normalize_job_class
|
|
85
|
+
|
|
86
|
+
def self.normalize_id(value, name)
|
|
87
|
+
id = value.to_s
|
|
88
|
+
raise ArgumentError, "dependency #{name} must not be blank" if id.strip.empty?
|
|
89
|
+
raise ArgumentError, "dependency #{name} contains unsupported characters" unless id.match?(SAFE_ID_PATTERN)
|
|
90
|
+
|
|
91
|
+
id
|
|
92
|
+
end
|
|
93
|
+
private_class_method :normalize_id
|
|
94
|
+
|
|
95
|
+
def self.hash_value(hash, key)
|
|
96
|
+
hash[key] || hash[key.to_s]
|
|
97
|
+
end
|
|
98
|
+
private_class_method :hash_value
|
|
99
|
+
|
|
100
|
+
def set(options = {})
|
|
101
|
+
enqueue_options = options.dup
|
|
102
|
+
dependencies_configured = enqueue_options.key?(:depends_on)
|
|
103
|
+
failure_policy_configured = enqueue_options.key?(:on_dependency_failure)
|
|
104
|
+
|
|
105
|
+
normalized_dependencies = if dependencies_configured
|
|
106
|
+
DependencyOptions.normalize(enqueue_options.delete(:depends_on))
|
|
107
|
+
end
|
|
108
|
+
normalized_failure_policy = normalize_dependency_failure_policy(
|
|
109
|
+
enqueue_options.delete(:on_dependency_failure),
|
|
110
|
+
failure_policy_configured
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
if failure_policy_configured && !dependencies_configured && normalized_dependencies.nil?
|
|
114
|
+
raise ArgumentError, "on_dependency_failure requires depends_on"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
super(enqueue_options).tap do
|
|
118
|
+
@temporal_dependencies = normalized_dependencies if dependencies_configured
|
|
119
|
+
@temporal_dependency_failure_policy = normalized_failure_policy || :fail if dependencies_configured
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
def normalize_dependency_failure_policy(policy, configured)
|
|
126
|
+
return unless configured
|
|
127
|
+
|
|
128
|
+
DependencyOptions.normalize_failure_policy(policy)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
ActiveJob::Base.prepend(ActiveJob::Temporal::DependencyOptions) if defined?(ActiveJob::Base)
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/duration"
|
|
4
|
+
|
|
5
|
+
module ActiveJob
|
|
6
|
+
module Temporal
|
|
7
|
+
class ExternalOperation
|
|
8
|
+
ACTIVITY = "activity"
|
|
9
|
+
WORKFLOW = "workflow"
|
|
10
|
+
|
|
11
|
+
attr_reader :operation, :temporal_type, :options
|
|
12
|
+
|
|
13
|
+
def self.activity(temporal_type, **options)
|
|
14
|
+
new(ACTIVITY, temporal_type, options)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.workflow(temporal_type, **options)
|
|
18
|
+
new(WORKFLOW, temporal_type, options)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.normalize(value)
|
|
22
|
+
return value.to_h if value.is_a?(self)
|
|
23
|
+
return normalize_hash(value) if external_operation_hash?(value)
|
|
24
|
+
|
|
25
|
+
nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.external_operation_hash?(value)
|
|
29
|
+
value.respond_to?(:[]) &&
|
|
30
|
+
!payload_value(value, :temporal_operation).nil? &&
|
|
31
|
+
!payload_value(value, :temporal_type).nil?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def initialize(operation, temporal_type, options)
|
|
35
|
+
@operation = normalize_operation(operation)
|
|
36
|
+
@temporal_type = normalize_temporal_type(temporal_type)
|
|
37
|
+
@options = ExternalOperationOptions.normalize(@operation, options)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def to_h
|
|
41
|
+
{
|
|
42
|
+
temporal_operation: operation,
|
|
43
|
+
temporal_type: temporal_type,
|
|
44
|
+
options: options.dup
|
|
45
|
+
}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
class << self
|
|
49
|
+
def normalize_hash(value)
|
|
50
|
+
operation = payload_value(value, :temporal_operation)
|
|
51
|
+
temporal_type = payload_value(value, :temporal_type)
|
|
52
|
+
options = payload_value(value, :options) || {}
|
|
53
|
+
|
|
54
|
+
new(operation, temporal_type, options).to_h
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def payload_value(payload, key)
|
|
58
|
+
payload[key] || payload[key.to_s]
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def normalize_operation(operation)
|
|
65
|
+
case operation.to_s
|
|
66
|
+
when ACTIVITY then ACTIVITY
|
|
67
|
+
when WORKFLOW then WORKFLOW
|
|
68
|
+
else raise ArgumentError, "external Temporal operation must be activity or workflow"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def normalize_temporal_type(value)
|
|
73
|
+
unless value.is_a?(String) && !value.strip.empty?
|
|
74
|
+
raise ArgumentError, "external Temporal type name must be a non-empty String"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
value
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
class ExternalOperationOptions
|
|
82
|
+
ACTIVITY_OPTION_KEYS = %i[
|
|
83
|
+
activity_id
|
|
84
|
+
heartbeat_timeout
|
|
85
|
+
retry_policy
|
|
86
|
+
schedule_to_close_timeout
|
|
87
|
+
schedule_to_start_timeout
|
|
88
|
+
start_to_close_timeout
|
|
89
|
+
summary
|
|
90
|
+
task_queue
|
|
91
|
+
].freeze
|
|
92
|
+
|
|
93
|
+
WORKFLOW_OPTION_KEYS = %i[
|
|
94
|
+
cron_schedule
|
|
95
|
+
execution_timeout
|
|
96
|
+
id
|
|
97
|
+
memo
|
|
98
|
+
retry_policy
|
|
99
|
+
run_timeout
|
|
100
|
+
static_details
|
|
101
|
+
static_summary
|
|
102
|
+
task_queue
|
|
103
|
+
task_timeout
|
|
104
|
+
].freeze
|
|
105
|
+
|
|
106
|
+
DURATION_OPTION_KEYS = %i[
|
|
107
|
+
execution_timeout
|
|
108
|
+
heartbeat_timeout
|
|
109
|
+
run_timeout
|
|
110
|
+
schedule_to_close_timeout
|
|
111
|
+
schedule_to_start_timeout
|
|
112
|
+
start_to_close_timeout
|
|
113
|
+
task_timeout
|
|
114
|
+
].freeze
|
|
115
|
+
|
|
116
|
+
RETRY_POLICY_DURATION_KEYS = %i[initial_interval max_interval].freeze
|
|
117
|
+
|
|
118
|
+
def self.normalize(operation, options)
|
|
119
|
+
supported_keys = option_keys_for(operation)
|
|
120
|
+
normalized_options = options.each_with_object({}) do |(key, value), normalized|
|
|
121
|
+
normalized_key = key.to_sym
|
|
122
|
+
unless supported_keys.include?(normalized_key)
|
|
123
|
+
raise ArgumentError, external_options_error(operation, supported_keys)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
normalized[normalized_key] = normalize_option_value(normalized_key, value)
|
|
127
|
+
end
|
|
128
|
+
normalize_task_queue!(normalized_options)
|
|
129
|
+
normalized_options
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
class << self
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
def option_keys_for(operation)
|
|
136
|
+
case operation.to_s
|
|
137
|
+
when ExternalOperation::ACTIVITY then ACTIVITY_OPTION_KEYS
|
|
138
|
+
when ExternalOperation::WORKFLOW then WORKFLOW_OPTION_KEYS
|
|
139
|
+
else raise ArgumentError, "external Temporal operation must be activity or workflow"
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def external_options_error(operation, supported_keys)
|
|
144
|
+
"external Temporal #{operation} options only support #{supported_keys.join(', ')}"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def normalize_option_value(key, value)
|
|
148
|
+
return normalize_duration(value) if DURATION_OPTION_KEYS.include?(key)
|
|
149
|
+
return normalize_retry_policy(value) if key == :retry_policy
|
|
150
|
+
return normalize_task_queue(value) if key == :task_queue
|
|
151
|
+
|
|
152
|
+
value
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def normalize_retry_policy(value)
|
|
156
|
+
value = value.to_h if value.respond_to?(:to_h) && !value.is_a?(Hash)
|
|
157
|
+
return value unless value.is_a?(Hash)
|
|
158
|
+
|
|
159
|
+
value.each_with_object({}) do |(key, retry_value), normalized|
|
|
160
|
+
normalized_key = key.to_sym
|
|
161
|
+
normalized[normalized_key] = retry_policy_value(normalized_key, retry_value)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def retry_policy_value(key, value)
|
|
166
|
+
return normalize_duration(value) if RETRY_POLICY_DURATION_KEYS.include?(key)
|
|
167
|
+
|
|
168
|
+
value
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def normalize_duration(value)
|
|
172
|
+
case value
|
|
173
|
+
when ActiveSupport::Duration, Numeric
|
|
174
|
+
value.to_f
|
|
175
|
+
else
|
|
176
|
+
raise ArgumentError, "Temporal timeout values must be numeric or ActiveSupport::Duration"
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def normalize_task_queue!(options)
|
|
181
|
+
options[:task_queue] = normalize_task_queue(options[:task_queue])
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def normalize_task_queue(value)
|
|
185
|
+
task_queue = value.to_s
|
|
186
|
+
return task_queue unless task_queue.strip.empty?
|
|
187
|
+
|
|
188
|
+
raise ArgumentError, "external Temporal steps require task_queue"
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|