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,215 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveJob
|
|
4
|
+
module Temporal
|
|
5
|
+
module Workflows
|
|
6
|
+
module WorkflowInteractions
|
|
7
|
+
HANDLER_NAME_PATTERN = /\A[a-zA-Z_]\w*\z/
|
|
8
|
+
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def pause_workflow(args)
|
|
12
|
+
workflow_state["paused"] = true
|
|
13
|
+
workflow_state["pause_reason"] = args.first if args.any?
|
|
14
|
+
record_signal("pause", args)
|
|
15
|
+
nil
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def resume_workflow(args)
|
|
19
|
+
workflow_state["paused"] = false
|
|
20
|
+
workflow_state.delete("pause_reason")
|
|
21
|
+
record_signal("resume", args)
|
|
22
|
+
nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def dispatch_custom_signal(handler_name, args)
|
|
26
|
+
unless workflow_interactions_configured?
|
|
27
|
+
buffered_custom_signals << [handler_name, deep_copy(args)]
|
|
28
|
+
return nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
dispatch_configured_custom_signal(handler_name, args)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def dispatch_configured_custom_signal(handler_name, args)
|
|
35
|
+
handler = workflow_signal_handlers[handler_name]
|
|
36
|
+
raise ArgumentError, "Unknown workflow signal: #{handler_name}" unless handler
|
|
37
|
+
|
|
38
|
+
handler.call(workflow_state["custom"], *args)
|
|
39
|
+
record_signal(handler_name, args)
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def dispatch_custom_query(handler_name, args)
|
|
44
|
+
handler = workflow_query_handlers[handler_name]
|
|
45
|
+
raise ArgumentError, "Unknown workflow query: #{handler_name}" unless handler
|
|
46
|
+
|
|
47
|
+
handler.call(deep_copy(workflow_state["custom"]), *args)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def dispatch_custom_update(handler_name, args)
|
|
51
|
+
handler = workflow_update_handlers[handler_name]
|
|
52
|
+
raise ArgumentError, "Unknown workflow update: #{handler_name}" unless handler
|
|
53
|
+
|
|
54
|
+
result = handler.call(workflow_state["custom"], *args)
|
|
55
|
+
record_update(handler_name, args)
|
|
56
|
+
result
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def record_signal(handler_name, args)
|
|
60
|
+
workflow_state["signals"][handler_name] = {
|
|
61
|
+
"args" => deep_copy(args),
|
|
62
|
+
"received_at" => Temporalio::Workflow.now.iso8601
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def record_update(handler_name, args)
|
|
67
|
+
workflow_state["updates"][handler_name] = {
|
|
68
|
+
"args" => deep_copy(args),
|
|
69
|
+
"accepted_at" => Temporalio::Workflow.now.iso8601
|
|
70
|
+
}
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def configure_workflow_state(payload)
|
|
74
|
+
restore_workflow_state(payload)
|
|
75
|
+
workflow_state["job_class"] = payload[:job_class] || payload["job_class"]
|
|
76
|
+
workflow_state["job_id"] = payload[:job_id] || payload["job_id"]
|
|
77
|
+
workflow_state["queue_name"] = payload[:queue_name] || payload["queue_name"]
|
|
78
|
+
workflow_state["phase"] = "initialized"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def restore_workflow_state(payload)
|
|
82
|
+
return unless workflow_patch_enabled?(:workflow_state)
|
|
83
|
+
|
|
84
|
+
state = payload[:workflow_state] || payload["workflow_state"]
|
|
85
|
+
return unless state.is_a?(Hash)
|
|
86
|
+
|
|
87
|
+
@workflow_state = deep_copy(state)
|
|
88
|
+
workflow_state["phase"] ||= "initialized"
|
|
89
|
+
workflow_state["paused"] = false unless workflow_state.key?("paused")
|
|
90
|
+
workflow_state["signals"] ||= {}
|
|
91
|
+
workflow_state["updates"] ||= {}
|
|
92
|
+
workflow_state["custom"] ||= {}
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def configure_workflow_interactions(payload)
|
|
96
|
+
reset_workflow_interactions
|
|
97
|
+
load_workflow_interaction_handlers(payload)
|
|
98
|
+
@workflow_interactions_configured = true
|
|
99
|
+
flush_buffered_custom_signals
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def reset_workflow_interactions
|
|
103
|
+
@workflow_signal_handlers = {}
|
|
104
|
+
@workflow_query_handlers = {}
|
|
105
|
+
@workflow_update_handlers = {}
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def load_workflow_interaction_handlers(payload)
|
|
109
|
+
metadata = payload[:workflow_interactions] || payload["workflow_interactions"]
|
|
110
|
+
return unless metadata
|
|
111
|
+
|
|
112
|
+
job_class = constant_from_name(metadata[:job_class] || metadata["job_class"])
|
|
113
|
+
return unless job_class
|
|
114
|
+
|
|
115
|
+
@workflow_signal_handlers = filtered_handlers(
|
|
116
|
+
job_class,
|
|
117
|
+
:temporal_signal_handlers,
|
|
118
|
+
metadata[:signals] || metadata["signals"]
|
|
119
|
+
)
|
|
120
|
+
@workflow_query_handlers = filtered_handlers(
|
|
121
|
+
job_class,
|
|
122
|
+
:temporal_query_handlers,
|
|
123
|
+
metadata[:queries] || metadata["queries"]
|
|
124
|
+
)
|
|
125
|
+
@workflow_update_handlers = filtered_handlers(
|
|
126
|
+
job_class,
|
|
127
|
+
:temporal_update_handlers,
|
|
128
|
+
metadata[:updates] || metadata["updates"]
|
|
129
|
+
)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def workflow_state
|
|
133
|
+
@workflow_state ||= {
|
|
134
|
+
"phase" => "initialized",
|
|
135
|
+
"paused" => false,
|
|
136
|
+
"signals" => {},
|
|
137
|
+
"updates" => {},
|
|
138
|
+
"custom" => {}
|
|
139
|
+
}
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def workflow_signal_handlers
|
|
143
|
+
@workflow_signal_handlers ||= {}
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def workflow_query_handlers
|
|
147
|
+
@workflow_query_handlers ||= {}
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def workflow_update_handlers
|
|
151
|
+
@workflow_update_handlers ||= {}
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def workflow_interactions_configured?
|
|
155
|
+
@workflow_interactions_configured == true
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def buffered_custom_signals
|
|
159
|
+
@buffered_custom_signals ||= []
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def flush_buffered_custom_signals
|
|
163
|
+
buffered_custom_signals.shift(buffered_custom_signals.length).each do |handler_name, args|
|
|
164
|
+
dispatch_configured_custom_signal(handler_name, args)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def filtered_handlers(job_class, method_name, handler_names)
|
|
169
|
+
return {} unless job_class.respond_to?(method_name)
|
|
170
|
+
|
|
171
|
+
allowed_names = Array(handler_names).map(&:to_s)
|
|
172
|
+
job_class.public_send(method_name).each_with_object({}) do |(name, handler), handlers|
|
|
173
|
+
handler_name = name.to_s
|
|
174
|
+
handlers[handler_name] = handler if allowed_names.include?(handler_name)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def constant_from_name(class_name)
|
|
179
|
+
class_name.to_s.split("::").reject(&:empty?).reduce(Object) do |namespace, constant_name|
|
|
180
|
+
namespace.const_get(constant_name, false)
|
|
181
|
+
end
|
|
182
|
+
rescue NameError
|
|
183
|
+
nil
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def normalize_handler_name!(name, handler_type)
|
|
187
|
+
handler_name = name.to_s
|
|
188
|
+
return handler_name if handler_name.match?(HANDLER_NAME_PATTERN)
|
|
189
|
+
|
|
190
|
+
raise ArgumentError, "#{handler_type} names must start with a letter or underscore and contain word chars"
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def wait_while_paused
|
|
194
|
+
return unless workflow_state["paused"]
|
|
195
|
+
|
|
196
|
+
workflow_state["phase"] = "paused"
|
|
197
|
+
Temporalio::Workflow.wait_condition { !workflow_state["paused"] }
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def deep_copy(value)
|
|
201
|
+
case value
|
|
202
|
+
when Hash
|
|
203
|
+
value.each_with_object({}) do |(key, entry_value), hash|
|
|
204
|
+
hash[key.to_s] = deep_copy(entry_value)
|
|
205
|
+
end
|
|
206
|
+
when Array
|
|
207
|
+
value.map { |entry_value| deep_copy(entry_value) }
|
|
208
|
+
else
|
|
209
|
+
value
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveJob
|
|
4
|
+
module Temporal
|
|
5
|
+
module Workflows
|
|
6
|
+
module WorkflowLocalActivities
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def execute_helper_activity(payload, helper_name, activity, *, **)
|
|
10
|
+
if local_activity_helper?(payload, helper_name)
|
|
11
|
+
Temporalio::Workflow.execute_local_activity(activity, *, **)
|
|
12
|
+
else
|
|
13
|
+
Temporalio::Workflow.execute_activity(activity, *, **)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def local_activity_helper?(payload, helper_name)
|
|
18
|
+
return false unless workflow_patch_enabled?(:local_activity_helpers)
|
|
19
|
+
|
|
20
|
+
local_activity_helper_names(payload).include?(helper_name.to_s)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def local_activity_helper_names(payload)
|
|
24
|
+
Array(payload[:local_activity_helpers] || payload["local_activity_helpers"]).map(&:to_s)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveJob
|
|
4
|
+
module Temporal
|
|
5
|
+
module Workflows
|
|
6
|
+
module WorkflowNexus
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def nexus_client_for(endpoint:, service:)
|
|
10
|
+
Temporalio::Workflow.create_nexus_client(endpoint: endpoint, service: service)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveJob
|
|
4
|
+
module Temporal
|
|
5
|
+
module Workflows
|
|
6
|
+
module WorkflowVersioning
|
|
7
|
+
PATCHES = {
|
|
8
|
+
continue_as_new: "activejob-temporal.continue-as-new-v1",
|
|
9
|
+
local_activity_helpers: "activejob-temporal.local-activity-helpers-v1",
|
|
10
|
+
workflow_state: "activejob-temporal.workflow-state-v1"
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def workflow_patch_enabled?(patch_name)
|
|
16
|
+
Temporalio::Workflow.patched(PATCHES.fetch(patch_name))
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "logger"
|
|
4
|
+
require "active_support/core_ext/numeric/time"
|
|
5
|
+
|
|
6
|
+
require_relative "temporal/version"
|
|
7
|
+
require_relative "temporal/configuration"
|
|
8
|
+
require_relative "temporal/configurable"
|
|
9
|
+
require_relative "temporal/client"
|
|
10
|
+
require_relative "temporal/logger"
|
|
11
|
+
require_relative "temporal/bind_policy"
|
|
12
|
+
require_relative "temporal/rails_environment_loader"
|
|
13
|
+
require_relative "temporal/tls_file"
|
|
14
|
+
require_relative "temporal/certificate_watcher"
|
|
15
|
+
require_relative "temporal/reload_signal_queue"
|
|
16
|
+
require_relative "temporal/worker_client_reloader"
|
|
17
|
+
require_relative "temporal/worker_pool"
|
|
18
|
+
require_relative "temporal/audit_log"
|
|
19
|
+
require_relative "temporal/metrics_server"
|
|
20
|
+
require_relative "temporal/observability"
|
|
21
|
+
require_relative "temporal/worker_health"
|
|
22
|
+
require_relative "temporal/health_check_server"
|
|
23
|
+
require_relative "temporal/middleware"
|
|
24
|
+
require_relative "temporal/payload_serializers"
|
|
25
|
+
require_relative "temporal/rate_limit_options"
|
|
26
|
+
require_relative "temporal/rate_limiters/memory"
|
|
27
|
+
require_relative "temporal/payload_encryption"
|
|
28
|
+
require_relative "temporal/payload_storage"
|
|
29
|
+
require_relative "temporal/payload"
|
|
30
|
+
require_relative "temporal/search_attributes"
|
|
31
|
+
require_relative "temporal/retry_mapper"
|
|
32
|
+
require_relative "temporal/job_descriptor"
|
|
33
|
+
require_relative "temporal/external_operation"
|
|
34
|
+
require_relative "temporal/signal_query_options"
|
|
35
|
+
require_relative "temporal/child_workflow_options"
|
|
36
|
+
require_relative "temporal/chain_options"
|
|
37
|
+
require_relative "temporal/dependency_options"
|
|
38
|
+
require_relative "temporal/workflow_identity"
|
|
39
|
+
require_relative "temporal/job_payload_builder"
|
|
40
|
+
require_relative "temporal/schedule_options"
|
|
41
|
+
require_relative "temporal/conditional_enqueue"
|
|
42
|
+
require_relative "temporal/job_tags"
|
|
43
|
+
require_relative "temporal/temporal_options"
|
|
44
|
+
require_relative "temporal/schedule"
|
|
45
|
+
require_relative "temporal/schedulable"
|
|
46
|
+
require_relative "temporal/workflow_id_builder"
|
|
47
|
+
require_relative "temporal/batch_enqueue_result"
|
|
48
|
+
require_relative "temporal/batch_enqueuer"
|
|
49
|
+
require_relative "temporal/workflow_enqueuer_batch"
|
|
50
|
+
require_relative "temporal/workflow_enqueuer"
|
|
51
|
+
require_relative "temporal/adapter"
|
|
52
|
+
require_relative "temporal/transaction_safety"
|
|
53
|
+
require_relative "temporal/workflows/aj_workflow"
|
|
54
|
+
require_relative "temporal/workflows/dead_letter_workflow"
|
|
55
|
+
require_relative "temporal/activities/rate_limit_activity"
|
|
56
|
+
require_relative "temporal/activities/dependency_status_activity"
|
|
57
|
+
require_relative "temporal/activities/aj_runner_activity"
|
|
58
|
+
require_relative "temporal/cancel"
|
|
59
|
+
require_relative "temporal/inspect"
|
|
60
|
+
require_relative "temporal/signal_query"
|
|
61
|
+
require_relative "temporal/dead_letter_queue"
|
|
62
|
+
|
|
63
|
+
module ActiveJob
|
|
64
|
+
# ActiveJob adapter for Temporal workflow orchestration.
|
|
65
|
+
#
|
|
66
|
+
# This gem provides a durable, fault-tolerant execution backend for Rails ActiveJob
|
|
67
|
+
# by leveraging Temporal's workflow engine. Jobs are executed as Temporal workflows
|
|
68
|
+
# with automatic retries, scheduling, and observability.
|
|
69
|
+
#
|
|
70
|
+
# @example Basic configuration
|
|
71
|
+
# ActiveJob::Temporal.configure do |config|
|
|
72
|
+
# config.target = "temporal.example.com:7233"
|
|
73
|
+
# config.namespace = "production"
|
|
74
|
+
# config.task_queue_prefix = "my-app"
|
|
75
|
+
# end
|
|
76
|
+
#
|
|
77
|
+
# @example Using the adapter in a job
|
|
78
|
+
# class MyJob < ApplicationJob
|
|
79
|
+
# self.queue_adapter = :temporal
|
|
80
|
+
#
|
|
81
|
+
# def perform(arg1, arg2)
|
|
82
|
+
# # Job logic here
|
|
83
|
+
# end
|
|
84
|
+
# end
|
|
85
|
+
#
|
|
86
|
+
# @example Complete configuration with error handling
|
|
87
|
+
# begin
|
|
88
|
+
# ActiveJob::Temporal.configure do |config|
|
|
89
|
+
# config.target = "temporal.example.com:7233"
|
|
90
|
+
# config.namespace = "production"
|
|
91
|
+
# config.default_activity_timeout = 10.minutes
|
|
92
|
+
# config.max_payload_size_kb = 250
|
|
93
|
+
# end
|
|
94
|
+
# ActiveJob::Temporal.config.validate!
|
|
95
|
+
# rescue ActiveJob::Temporal::ConfigurationError => e
|
|
96
|
+
# Rails.logger.error("Temporal configuration invalid: #{e.message}")
|
|
97
|
+
# raise
|
|
98
|
+
# end
|
|
99
|
+
#
|
|
100
|
+
# @see https://github.com/temporalio/sdk-ruby Temporal Ruby SDK
|
|
101
|
+
module Temporal
|
|
102
|
+
# Raised when attempting to cancel a job that does not exist.
|
|
103
|
+
#
|
|
104
|
+
# @see Cancel.cancel
|
|
105
|
+
class WorkflowNotFoundError < Error; end
|
|
106
|
+
|
|
107
|
+
# Raised when Temporal cluster is unreachable.
|
|
108
|
+
#
|
|
109
|
+
# @see Client.build
|
|
110
|
+
# @see Cancel.cancel
|
|
111
|
+
class TemporalConnectionError < Error; end
|
|
112
|
+
|
|
113
|
+
extend Configurable
|
|
114
|
+
|
|
115
|
+
class << self
|
|
116
|
+
# Returns the memoized Temporal client connection for the process.
|
|
117
|
+
#
|
|
118
|
+
# The client is connected to the Temporal server specified in the configuration.
|
|
119
|
+
# TLS options can be provided via configuration attributes or environment variables:
|
|
120
|
+
# - TEMPORAL_TLS_CERT: TLS certificate
|
|
121
|
+
# - TEMPORAL_TLS_KEY: TLS private key
|
|
122
|
+
# - TEMPORAL_TLS_SERVER_NAME: TLS server name
|
|
123
|
+
#
|
|
124
|
+
# @return [Temporalio::Client] the connected Temporal client
|
|
125
|
+
# @raise [ActiveJob::Temporal::Error] if connection fails due to network or authentication issues
|
|
126
|
+
# @raise [ActiveJob::Temporal::TemporalConnectionError] if Temporal cluster is unreachable
|
|
127
|
+
# @raise [Errno::ECONNREFUSED] if Temporal cluster is not accepting connections
|
|
128
|
+
# @raise [SocketError] if Temporal hostname cannot be resolved
|
|
129
|
+
# @raise [OpenSSL::SSL::SSLError] if TLS configuration is invalid
|
|
130
|
+
# @example Get the client
|
|
131
|
+
# client = ActiveJob::Temporal.client
|
|
132
|
+
# client.list_workflows("ajQueue='default'")
|
|
133
|
+
#
|
|
134
|
+
# @example Using client for workflow queries
|
|
135
|
+
# client = ActiveJob::Temporal.client
|
|
136
|
+
# workflows = client.list_workflows("ajClass='MyJob'")
|
|
137
|
+
# workflows.each { |wf| puts wf.id }
|
|
138
|
+
#
|
|
139
|
+
# @example Accessing workflow handles
|
|
140
|
+
# client = ActiveJob::Temporal.client
|
|
141
|
+
# handle = client.workflow_handle("ajwf:MyJob:abc-123")
|
|
142
|
+
# result = handle.result
|
|
143
|
+
#
|
|
144
|
+
# @see Client.build
|
|
145
|
+
def client
|
|
146
|
+
client_mutex.synchronize do
|
|
147
|
+
@client ||= Client.build(config)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def reload_client!
|
|
152
|
+
fresh_client = Client.build(config)
|
|
153
|
+
begin
|
|
154
|
+
yield fresh_client if block_given?
|
|
155
|
+
rescue StandardError
|
|
156
|
+
close_client(fresh_client)
|
|
157
|
+
raise
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
previous_client = nil
|
|
161
|
+
|
|
162
|
+
client_mutex.synchronize do
|
|
163
|
+
previous_client = @client
|
|
164
|
+
@client = fresh_client
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
close_client(previous_client) unless previous_client.equal?(fresh_client)
|
|
168
|
+
fresh_client
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Cancels a running or scheduled job by job ID.
|
|
172
|
+
#
|
|
173
|
+
# This method requests cancellation for the Temporal workflow associated with the job.
|
|
174
|
+
# Cancellation is asynchronous and best-effort: the job will stop only if it is actively
|
|
175
|
+
# heartbeating. See Cancel module documentation for details.
|
|
176
|
+
#
|
|
177
|
+
# @param job_class [Class] the ActiveJob class (used to determine task queue)
|
|
178
|
+
# @param job_id [String] the unique job identifier
|
|
179
|
+
# @return [Boolean, nil] false if workflow already completed, nil if cancellation requested
|
|
180
|
+
# @raise [ActiveJob::Temporal::WorkflowNotFoundError] if job never existed or already removed from history
|
|
181
|
+
# @raise [ActiveJob::Temporal::TemporalConnectionError] if Temporal cluster is unreachable
|
|
182
|
+
# @example Cancel a scheduled job
|
|
183
|
+
# ActiveJob::Temporal.cancel(MyJob, "job-123-abc")
|
|
184
|
+
# @example Handle cancellation outcomes
|
|
185
|
+
# result = ActiveJob::Temporal.cancel(MyJob, "abc-123")
|
|
186
|
+
# case result
|
|
187
|
+
# when false
|
|
188
|
+
# puts "Job already completed"
|
|
189
|
+
# when nil
|
|
190
|
+
# puts "Cancellation requested"
|
|
191
|
+
# end
|
|
192
|
+
#
|
|
193
|
+
# @example Cancel with error handling
|
|
194
|
+
# begin
|
|
195
|
+
# ActiveJob::Temporal.cancel(MyJob, "unknown-id")
|
|
196
|
+
# rescue ActiveJob::Temporal::WorkflowNotFoundError
|
|
197
|
+
# puts "Job does not exist"
|
|
198
|
+
# end
|
|
199
|
+
#
|
|
200
|
+
# @note Cancellation Requires Heartbeating
|
|
201
|
+
# For jobs to respond to cancellation, they must check for cancellation by heartbeating
|
|
202
|
+
# or polling Temporalio::Activity::Context.current.cancelled?. Without heartbeating,
|
|
203
|
+
# long-running activities will complete before they detect the cancellation signal.
|
|
204
|
+
#
|
|
205
|
+
# @see Cancel.cancel
|
|
206
|
+
def cancel(job_class, job_id)
|
|
207
|
+
Cancel.cancel(job_class, job_id)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def cancel_all(job_class)
|
|
211
|
+
Cancel.cancel_all(job_class)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def cancel_where(filters)
|
|
215
|
+
Cancel.cancel_where(filters)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def enqueue_batch(items, concurrency: 1)
|
|
219
|
+
WorkflowEnqueuer.new(
|
|
220
|
+
-> { client },
|
|
221
|
+
config,
|
|
222
|
+
config.logger
|
|
223
|
+
).enqueue_batch(items, concurrency: concurrency)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def activity(temporal_type, **)
|
|
227
|
+
ExternalOperation.activity(temporal_type, **)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def workflow(temporal_type, **)
|
|
231
|
+
ExternalOperation.workflow(temporal_type, **)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def job(job_class, **options)
|
|
235
|
+
JobDescriptor.new(job_class, options)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def status(job_class, job_id)
|
|
239
|
+
Inspect.status(job_class, job_id)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def signal(job_class, job_id, signal_name, *)
|
|
243
|
+
SignalQuery.signal(job_class, job_id, signal_name, *)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def query(job_class, job_id, query_name, *, reject_condition: SignalQuery::DEFAULT_REJECT_CONDITION)
|
|
247
|
+
SignalQuery.query(job_class, job_id, query_name, *, reject_condition: reject_condition)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def update(job_class, job_id, update_name, *)
|
|
251
|
+
SignalQuery.update(job_class, job_id, update_name, *)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def running?(job_class, job_id)
|
|
255
|
+
Inspect.running?(job_class, job_id)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def completed?(job_class, job_id)
|
|
259
|
+
Inspect.completed?(job_class, job_id)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def failed?(job_class, job_id)
|
|
263
|
+
Inspect.failed?(job_class, job_id)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def dead_letter_entry(job_class, job_id)
|
|
267
|
+
DeadLetterQueue.entry(job_class, job_id)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def dead_letter_entries(queue: nil, limit: DeadLetterQueue::DEFAULT_ENTRIES_LIMIT)
|
|
271
|
+
DeadLetterQueue.entries(queue: queue, limit: limit)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def retry_dead_letter(job_class, job_id, queue: nil)
|
|
275
|
+
DeadLetterQueue.retry(job_class, job_id, queue: queue)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def discard_dead_letter(job_class, job_id, reason: nil)
|
|
279
|
+
DeadLetterQueue.discard(job_class, job_id, reason: reason)
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
private
|
|
283
|
+
|
|
284
|
+
def client_mutex
|
|
285
|
+
@client_mutex ||= Mutex.new
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def close_client(client)
|
|
289
|
+
return unless client.respond_to?(:close)
|
|
290
|
+
|
|
291
|
+
client.close
|
|
292
|
+
rescue StandardError
|
|
293
|
+
nil
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
end
|