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,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "io/wait"
|
|
5
|
+
require "socket"
|
|
6
|
+
|
|
7
|
+
require_relative "connection_worker_pool"
|
|
8
|
+
require_relative "bind_policy"
|
|
9
|
+
require_relative "http_line_reader"
|
|
10
|
+
|
|
11
|
+
module ActiveJob
|
|
12
|
+
module Temporal
|
|
13
|
+
class HealthCheckServer
|
|
14
|
+
include HttpLineReader
|
|
15
|
+
|
|
16
|
+
DEFAULT_BIND_ADDRESS = "127.0.0.1"
|
|
17
|
+
READ_TIMEOUT_SECONDS = 1
|
|
18
|
+
CONNECTION_WORKERS = 4
|
|
19
|
+
CONNECTION_QUEUE_SIZE = 16
|
|
20
|
+
|
|
21
|
+
attr_reader :port, :bind_address
|
|
22
|
+
|
|
23
|
+
def initialize(port:, state:, bind_address: DEFAULT_BIND_ADDRESS, allow_public_bind: false)
|
|
24
|
+
@requested_port = Integer(port)
|
|
25
|
+
@bind_address = bind_address
|
|
26
|
+
@allow_public_bind = allow_public_bind
|
|
27
|
+
@state = state
|
|
28
|
+
@running = false
|
|
29
|
+
@mutex = Mutex.new
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def start
|
|
33
|
+
@mutex.synchronize do
|
|
34
|
+
return self if @running
|
|
35
|
+
|
|
36
|
+
BindPolicy.validate!(
|
|
37
|
+
endpoint: "health check", bind_address: bind_address, allow_public_bind: @allow_public_bind
|
|
38
|
+
)
|
|
39
|
+
@server = TCPServer.new(bind_address, @requested_port)
|
|
40
|
+
@port = @server.addr[1]
|
|
41
|
+
@connection_pool = ConnectionWorkerPool.new(
|
|
42
|
+
size: CONNECTION_WORKERS,
|
|
43
|
+
queue_size: CONNECTION_QUEUE_SIZE,
|
|
44
|
+
name: "activejob-temporal-health"
|
|
45
|
+
) { |client| serve_client(client) }.start
|
|
46
|
+
@running = true
|
|
47
|
+
@thread = Thread.new { run }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
self
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def stop
|
|
54
|
+
server = nil
|
|
55
|
+
thread = nil
|
|
56
|
+
connection_pool = nil
|
|
57
|
+
|
|
58
|
+
@mutex.synchronize do
|
|
59
|
+
server = @server
|
|
60
|
+
thread = @thread
|
|
61
|
+
connection_pool = @connection_pool
|
|
62
|
+
@server = nil
|
|
63
|
+
@thread = nil
|
|
64
|
+
@connection_pool = nil
|
|
65
|
+
@running = false
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
server&.close
|
|
69
|
+
connection_pool&.stop(timeout: 2)
|
|
70
|
+
thread&.join(2)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def running?
|
|
74
|
+
@mutex.synchronize { @running }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def run
|
|
80
|
+
loop do
|
|
81
|
+
server, connection_pool = @mutex.synchronize { [@server, @connection_pool] }
|
|
82
|
+
break unless server && connection_pool
|
|
83
|
+
|
|
84
|
+
connection_pool.enqueue(server.accept)
|
|
85
|
+
rescue IOError, Errno::EBADF
|
|
86
|
+
break
|
|
87
|
+
end
|
|
88
|
+
ensure
|
|
89
|
+
@mutex.synchronize { @running = false if @server }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def serve_client(client)
|
|
93
|
+
handle_client(client)
|
|
94
|
+
rescue IOError, SystemCallError
|
|
95
|
+
nil
|
|
96
|
+
ensure
|
|
97
|
+
client&.close
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def handle_client(client)
|
|
101
|
+
request_line = read_line(client)
|
|
102
|
+
return unless request_line
|
|
103
|
+
|
|
104
|
+
method, path = request_line.split.first(2)
|
|
105
|
+
drain_headers(client)
|
|
106
|
+
|
|
107
|
+
unless method && path
|
|
108
|
+
write_json(client, 400, { error: "bad_request" })
|
|
109
|
+
return
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
case [method, path]
|
|
113
|
+
in ["GET" | "HEAD", "/health"]
|
|
114
|
+
payload = health_payload
|
|
115
|
+
write_json(client, health_status(payload), payload, body: method == "GET")
|
|
116
|
+
in [_, "/health"]
|
|
117
|
+
write_json(client, 405, { error: "method_not_allowed" })
|
|
118
|
+
else
|
|
119
|
+
write_json(client, 404, { error: "not_found" })
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def health_payload
|
|
124
|
+
@state.respond_to?(:snapshot) ? @state.snapshot : @state.call
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def health_status(payload)
|
|
128
|
+
payload[:worker_running] ? 200 : 503
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def drain_headers(client)
|
|
132
|
+
loop do
|
|
133
|
+
line = read_line(client)
|
|
134
|
+
break if line.nil? || line == "\r\n" || line == "\n"
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def write_json(client, status, payload, body: true)
|
|
139
|
+
json = JSON.generate(payload)
|
|
140
|
+
response = "HTTP/1.1 #{status} #{reason_phrase(status)}\r\n"
|
|
141
|
+
response << "Content-Type: application/json\r\n"
|
|
142
|
+
response << "Content-Length: #{body ? json.bytesize : 0}\r\n"
|
|
143
|
+
response << "Connection: close\r\n\r\n"
|
|
144
|
+
response << json if body
|
|
145
|
+
client.write(response)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def reason_phrase(status)
|
|
149
|
+
{
|
|
150
|
+
200 => "OK",
|
|
151
|
+
400 => "Bad Request",
|
|
152
|
+
404 => "Not Found",
|
|
153
|
+
405 => "Method Not Allowed",
|
|
154
|
+
503 => "Service Unavailable"
|
|
155
|
+
}.fetch(status)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveJob
|
|
4
|
+
module Temporal
|
|
5
|
+
module HttpLineReader
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def read_line(client)
|
|
9
|
+
deadline = monotonic_time + self.class.const_get(:READ_TIMEOUT_SECONDS)
|
|
10
|
+
buffer = +""
|
|
11
|
+
|
|
12
|
+
loop do
|
|
13
|
+
remaining = deadline - monotonic_time
|
|
14
|
+
return if remaining <= 0 || !client.wait_readable(remaining)
|
|
15
|
+
|
|
16
|
+
chunk = client.read_nonblock(1, exception: false)
|
|
17
|
+
case chunk
|
|
18
|
+
when :wait_readable
|
|
19
|
+
next
|
|
20
|
+
when nil
|
|
21
|
+
return buffer unless buffer.empty?
|
|
22
|
+
|
|
23
|
+
return
|
|
24
|
+
else
|
|
25
|
+
buffer << chunk
|
|
26
|
+
return buffer if chunk == "\n"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def monotonic_time
|
|
32
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "temporalio/client/workflow_execution_status"
|
|
4
|
+
require "temporalio/error"
|
|
5
|
+
require_relative "visibility_query"
|
|
6
|
+
require_relative "workflow_id_builder"
|
|
7
|
+
|
|
8
|
+
module ActiveJob
|
|
9
|
+
module Temporal
|
|
10
|
+
module Inspect
|
|
11
|
+
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
|
|
12
|
+
JOB_CLASS_NAME_PATTERN = /\A[A-Z]\w*(?:::[A-Z]\w*)*\z/
|
|
13
|
+
WORKFLOW_STATES = {
|
|
14
|
+
Temporalio::Client::WorkflowExecutionStatus::RUNNING => :running,
|
|
15
|
+
Temporalio::Client::WorkflowExecutionStatus::COMPLETED => :completed,
|
|
16
|
+
Temporalio::Client::WorkflowExecutionStatus::FAILED => :failed,
|
|
17
|
+
Temporalio::Client::WorkflowExecutionStatus::CANCELED => :canceled,
|
|
18
|
+
Temporalio::Client::WorkflowExecutionStatus::TERMINATED => :terminated,
|
|
19
|
+
Temporalio::Client::WorkflowExecutionStatus::CONTINUED_AS_NEW => :continued_as_new,
|
|
20
|
+
Temporalio::Client::WorkflowExecutionStatus::TIMED_OUT => :timed_out
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
class << self
|
|
24
|
+
def status(job_class, job_id)
|
|
25
|
+
validate_job_class!(job_class)
|
|
26
|
+
validate_job_id!(job_id)
|
|
27
|
+
|
|
28
|
+
client = ActiveJob::Temporal.client
|
|
29
|
+
describe_default_workflow(client, job_class, job_id) ||
|
|
30
|
+
describe_search_attribute_workflow(client, job_class, job_id)
|
|
31
|
+
rescue ArgumentError
|
|
32
|
+
raise
|
|
33
|
+
rescue StandardError => e
|
|
34
|
+
raise ActiveJob::Temporal::TemporalConnectionError,
|
|
35
|
+
"Failed to inspect Temporal workflow for job_id #{job_id}: #{e.message}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def running?(job_class, job_id) = workflow_state?(job_class, job_id, :running)
|
|
39
|
+
|
|
40
|
+
def completed?(job_class, job_id) = workflow_state?(job_class, job_id, :completed)
|
|
41
|
+
|
|
42
|
+
def failed?(job_class, job_id) = workflow_state?(job_class, job_id, :failed)
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def workflow_state?(job_class, job_id, state)
|
|
47
|
+
status(job_class, job_id)&.fetch(:state) == state
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def validate_job_class!(job_class)
|
|
51
|
+
unless job_class.is_a?(Class) && !job_class.name.to_s.empty?
|
|
52
|
+
raise ArgumentError, "job_class must be a named class"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
return if job_class.name.match?(JOB_CLASS_NAME_PATTERN)
|
|
56
|
+
|
|
57
|
+
raise ArgumentError, "job_class must have a valid constant name"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def validate_job_id!(job_id)
|
|
61
|
+
return if job_id.is_a?(String) && job_id.match?(UUID_REGEX)
|
|
62
|
+
|
|
63
|
+
raise ArgumentError,
|
|
64
|
+
"Invalid job_id format: expected UUID (e.g., '550e8400-e29b-41d4-a716-446655440000'), " \
|
|
65
|
+
"got: #{job_id.inspect}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def describe_default_workflow(client, job_class, job_id)
|
|
69
|
+
describe_workflow(client, default_workflow_reference(job_class, job_id))
|
|
70
|
+
rescue StandardError => e
|
|
71
|
+
raise unless rpc_not_found?(e)
|
|
72
|
+
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def describe_search_attribute_workflow(client, job_class, job_id)
|
|
77
|
+
workflow_reference = find_workflow_reference(client, job_class, job_id)
|
|
78
|
+
return unless workflow_reference
|
|
79
|
+
|
|
80
|
+
describe_workflow(client, workflow_reference)
|
|
81
|
+
rescue StandardError => e
|
|
82
|
+
return nil if rpc_not_found?(e) || rpc_invalid_argument?(e)
|
|
83
|
+
|
|
84
|
+
raise
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def find_workflow_reference(client, job_class, job_id)
|
|
88
|
+
workflow = client.list_workflows(workflow_search_query(job_class, job_id)).first
|
|
89
|
+
return unless workflow
|
|
90
|
+
|
|
91
|
+
{
|
|
92
|
+
workflow_id: workflow.id,
|
|
93
|
+
run_id: workflow.respond_to?(:run_id) ? workflow.run_id : nil
|
|
94
|
+
}
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def workflow_search_query(job_class, job_id)
|
|
98
|
+
"ajClass=#{VisibilityQuery.quote(job_class.name)} AND ajJobId=#{VisibilityQuery.quote(job_id)}"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def default_workflow_reference(job_class, job_id)
|
|
102
|
+
{
|
|
103
|
+
workflow_id: WorkflowIdBuilder.new.build_from_job_class(job_class, job_id),
|
|
104
|
+
run_id: nil
|
|
105
|
+
}
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def describe_workflow(client, workflow_reference)
|
|
109
|
+
handle = client.workflow_handle(
|
|
110
|
+
workflow_reference.fetch(:workflow_id),
|
|
111
|
+
run_id: workflow_reference[:run_id]
|
|
112
|
+
)
|
|
113
|
+
status_from_description(handle.describe)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def status_from_description(description)
|
|
117
|
+
pending_activity = pending_activity(description)
|
|
118
|
+
|
|
119
|
+
{
|
|
120
|
+
state: WORKFLOW_STATES.fetch(description.status, :unknown),
|
|
121
|
+
workflow_id: description.id,
|
|
122
|
+
run_id: description.run_id,
|
|
123
|
+
started_at: description.start_time,
|
|
124
|
+
closed_at: description.close_time,
|
|
125
|
+
attempt: activity_attempt(pending_activity),
|
|
126
|
+
last_failure: activity_last_failure(pending_activity)
|
|
127
|
+
}
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def pending_activity(description)
|
|
131
|
+
return unless description.respond_to?(:raw_description)
|
|
132
|
+
return unless description.raw_description.respond_to?(:pending_activities)
|
|
133
|
+
|
|
134
|
+
activities = description.raw_description.pending_activities
|
|
135
|
+
return if activities.nil? || activities.empty?
|
|
136
|
+
|
|
137
|
+
activities.max_by { |activity| activity_attempt(activity).to_i }
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def activity_attempt(activity)
|
|
141
|
+
return unless activity.respond_to?(:attempt)
|
|
142
|
+
|
|
143
|
+
activity.attempt
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def activity_last_failure(activity)
|
|
147
|
+
return unless activity.respond_to?(:last_failure)
|
|
148
|
+
|
|
149
|
+
format_failure(activity.last_failure)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def format_failure(failure)
|
|
153
|
+
return unless failure
|
|
154
|
+
|
|
155
|
+
type = failure_type(failure)
|
|
156
|
+
message = failure.respond_to?(:message) ? failure.message.to_s : failure.to_s
|
|
157
|
+
failure_details = [type, message].compact.reject(&:empty?)
|
|
158
|
+
return if failure_details.empty?
|
|
159
|
+
|
|
160
|
+
failure_details.join(": ")
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def failure_type(failure)
|
|
164
|
+
return unless failure.respond_to?(:application_failure_info)
|
|
165
|
+
return unless failure.application_failure_info.respond_to?(:type)
|
|
166
|
+
|
|
167
|
+
failure.application_failure_info.type.to_s
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def rpc_not_found?(error)
|
|
171
|
+
defined?(Temporalio::Error::RPCError) &&
|
|
172
|
+
error.is_a?(Temporalio::Error::RPCError) &&
|
|
173
|
+
error.code == Temporalio::Error::RPCError::Code::NOT_FOUND
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def rpc_invalid_argument?(error)
|
|
177
|
+
defined?(Temporalio::Error::RPCError) &&
|
|
178
|
+
error.is_a?(Temporalio::Error::RPCError) &&
|
|
179
|
+
error.code == Temporalio::Error::RPCError::Code::INVALID_ARGUMENT
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_job"
|
|
4
|
+
|
|
5
|
+
module ActiveJob
|
|
6
|
+
module Temporal
|
|
7
|
+
class JobDescriptor
|
|
8
|
+
attr_reader :job_class, :options
|
|
9
|
+
|
|
10
|
+
def self.normalize(value)
|
|
11
|
+
return value.to_h if value.is_a?(self)
|
|
12
|
+
|
|
13
|
+
nil
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(job_class, options = {})
|
|
17
|
+
@job_class = normalize_job_class(job_class)
|
|
18
|
+
@options = options.dup
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def to_h
|
|
22
|
+
{
|
|
23
|
+
job_class: job_class.name,
|
|
24
|
+
options: options.dup
|
|
25
|
+
}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def normalize_job_class(job_class)
|
|
31
|
+
return job_class if job_class.is_a?(Class) && job_class < ActiveJob::Base && job_class.name
|
|
32
|
+
|
|
33
|
+
raise ArgumentError, "Temporal job descriptors require a named ActiveJob class"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "payload"
|
|
4
|
+
require_relative "job_payload_child_workflows"
|
|
5
|
+
require_relative "job_payload_chain_builder"
|
|
6
|
+
require_relative "job_payload_dependencies"
|
|
7
|
+
require_relative "job_payload_rate_limits"
|
|
8
|
+
require_relative "job_payload_workflow_interactions"
|
|
9
|
+
require_relative "observability"
|
|
10
|
+
require_relative "retry_mapper"
|
|
11
|
+
|
|
12
|
+
module ActiveJob
|
|
13
|
+
module Temporal
|
|
14
|
+
# rubocop:disable Metrics/ClassLength
|
|
15
|
+
class JobPayloadBuilder
|
|
16
|
+
include JobPayloadChildWorkflows
|
|
17
|
+
include JobPayloadChainBuilder
|
|
18
|
+
include JobPayloadDependencies
|
|
19
|
+
include JobPayloadRateLimits
|
|
20
|
+
include JobPayloadWorkflowInteractions
|
|
21
|
+
|
|
22
|
+
TIMEOUT_CONFIG_ATTRIBUTES = {
|
|
23
|
+
default_activity_timeout: :start_to_close_timeout,
|
|
24
|
+
default_schedule_to_close_timeout: :schedule_to_close_timeout,
|
|
25
|
+
default_schedule_to_start_timeout: :schedule_to_start_timeout,
|
|
26
|
+
default_heartbeat_timeout: :heartbeat_timeout
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
def initialize(config)
|
|
30
|
+
@config = config
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def build(job, scheduled_at: nil, encryption_context: nil)
|
|
34
|
+
payload = base_payload_for(job, scheduled_at)
|
|
35
|
+
payload[:default_activity_options] = default_activity_options
|
|
36
|
+
Observability.inject_trace_context(payload, observability_attributes_for(job, encryption_context))
|
|
37
|
+
|
|
38
|
+
apply_retry_policy(payload, job)
|
|
39
|
+
apply_temporal_options(payload, job.class)
|
|
40
|
+
apply_workflow_identity(payload, job.class)
|
|
41
|
+
apply_rate_limits(payload, job)
|
|
42
|
+
apply_workflow_interactions(payload, job.class)
|
|
43
|
+
apply_child_workflows(payload, job)
|
|
44
|
+
apply_chain(payload, job)
|
|
45
|
+
apply_dependencies(payload, job)
|
|
46
|
+
apply_continue_as_new(payload)
|
|
47
|
+
apply_local_activity_helpers(payload)
|
|
48
|
+
|
|
49
|
+
payload = transport_payload(payload, job, scheduled_at, encryption_context)
|
|
50
|
+
Payload.enforce_size!(payload, metrics_payload: metrics_payload_for(job), config: @config)
|
|
51
|
+
payload
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def base_payload_for(job, scheduled_at)
|
|
57
|
+
Payload.from_job(
|
|
58
|
+
job,
|
|
59
|
+
scheduled_at:,
|
|
60
|
+
enforce_size: false,
|
|
61
|
+
encrypt: false,
|
|
62
|
+
offload: false,
|
|
63
|
+
config: @config
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def apply_retry_policy(payload, job)
|
|
68
|
+
retry_policy = retry_policy_for(job.class)
|
|
69
|
+
payload[:retry_policy] = retry_policy
|
|
70
|
+
return unless dead_letter_enabled?
|
|
71
|
+
|
|
72
|
+
payload[:dead_letter] = dead_letter_metadata(job.class.name, job.job_id, job.queue_name, retry_policy)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def apply_temporal_options(payload, job_class)
|
|
76
|
+
temporal_options = extract_temporal_options(job_class)
|
|
77
|
+
payload[:temporal_options] = temporal_options if temporal_options.any?
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def apply_workflow_identity(payload, job_class)
|
|
81
|
+
workflow_name = extract_temporal_workflow_name(job_class)
|
|
82
|
+
return unless workflow_name
|
|
83
|
+
|
|
84
|
+
identity = { workflow_name: workflow_name }
|
|
85
|
+
workflow_id_prefix = extract_temporal_workflow_id_prefix(job_class)
|
|
86
|
+
identity[:workflow_id_prefix] = workflow_id_prefix if workflow_id_prefix
|
|
87
|
+
payload[:workflow_identity] = identity
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def metrics_payload_for(job)
|
|
91
|
+
{
|
|
92
|
+
job_class: job.class.name,
|
|
93
|
+
job_id: job.job_id,
|
|
94
|
+
queue_name: job.queue_name
|
|
95
|
+
}
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def observability_attributes_for(job, encryption_context)
|
|
99
|
+
Observability.attributes_from_job(
|
|
100
|
+
job,
|
|
101
|
+
workflow_id: encryption_context&.fetch(:workflow_id, nil),
|
|
102
|
+
namespace: @config.namespace,
|
|
103
|
+
task_queue: Adapter.resolve_task_queue(job, config: @config)
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def transport_payload(payload, job, scheduled_at, encryption_context)
|
|
108
|
+
encrypted_payload = Payload.encrypt_payload(payload, config: @config, encryption_context: encryption_context)
|
|
109
|
+
Payload.offload_payload(
|
|
110
|
+
encrypted_payload,
|
|
111
|
+
metadata: storage_metadata_for(job, scheduled_at, encryption_context),
|
|
112
|
+
config: @config
|
|
113
|
+
)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def storage_metadata_for(job, scheduled_at, encryption_context)
|
|
117
|
+
metrics_payload_for(job).merge(
|
|
118
|
+
namespace: @config.namespace,
|
|
119
|
+
workflow_id: encryption_context&.fetch(:workflow_id, nil),
|
|
120
|
+
scheduled_at: scheduled_at.respond_to?(:iso8601) ? scheduled_at.iso8601 : scheduled_at
|
|
121
|
+
)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def apply_continue_as_new(payload)
|
|
125
|
+
threshold = @config.continue_as_new_history_event_threshold
|
|
126
|
+
return unless threshold
|
|
127
|
+
|
|
128
|
+
payload[:continue_as_new] = { history_event_threshold: threshold }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def apply_local_activity_helpers(payload)
|
|
132
|
+
helpers = Array(@config.local_activity_helpers).filter_map do |helper|
|
|
133
|
+
helper_name = helper.to_s.strip
|
|
134
|
+
helper_name unless helper_name.empty?
|
|
135
|
+
end.uniq
|
|
136
|
+
payload[:local_activity_helpers] = helpers if helpers.any?
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def extract_temporal_options(job_class)
|
|
140
|
+
return {} unless job_class.respond_to?(:temporal_options)
|
|
141
|
+
|
|
142
|
+
job_class.temporal_options
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def extract_temporal_workflow_name(job_class)
|
|
146
|
+
return unless job_class.respond_to?(:temporal_workflow_name)
|
|
147
|
+
|
|
148
|
+
job_class.temporal_workflow_name
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def extract_temporal_workflow_id_prefix(job_class)
|
|
152
|
+
return unless job_class.respond_to?(:temporal_workflow_id_prefix)
|
|
153
|
+
|
|
154
|
+
job_class.temporal_workflow_id_prefix
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def dead_letter_enabled?
|
|
158
|
+
@config.respond_to?(:dead_letter_queue) && @config.dead_letter_queue.to_s.strip.present?
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def apply_dead_letter_attempt_limit(retry_policy)
|
|
162
|
+
return unless dead_letter_enabled?
|
|
163
|
+
return unless @config.dead_letter_after_attempts
|
|
164
|
+
|
|
165
|
+
retry_policy[:maximum_attempts] = @config.dead_letter_after_attempts
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def dead_letter_metadata(job_class_name, job_id, queue_name, retry_policy, task_queue: nil)
|
|
169
|
+
{
|
|
170
|
+
queue: @config.dead_letter_queue,
|
|
171
|
+
job_class: job_class_name,
|
|
172
|
+
job_id: job_id,
|
|
173
|
+
queue_name: queue_name,
|
|
174
|
+
task_queue: task_queue,
|
|
175
|
+
after_attempts: dead_letter_attempt_limit(retry_policy),
|
|
176
|
+
auto_discard_after_seconds: @config.dead_letter_auto_discard_after&.to_f
|
|
177
|
+
}.compact
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def dead_letter_attempt_limit(retry_policy)
|
|
181
|
+
configured_limit = @config.dead_letter_after_attempts
|
|
182
|
+
return configured_limit if configured_limit
|
|
183
|
+
|
|
184
|
+
attempts = retry_policy[:maximum_attempts] || retry_policy["maximum_attempts"]
|
|
185
|
+
attempts if attempts.respond_to?(:positive?) && attempts.positive?
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def retry_policy_for(job_class)
|
|
189
|
+
retry_policy = RetryMapper.for(job_class)
|
|
190
|
+
apply_dead_letter_attempt_limit(retry_policy)
|
|
191
|
+
retry_policy
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def default_activity_options
|
|
195
|
+
TIMEOUT_CONFIG_ATTRIBUTES.each_with_object({}) do |(config_attribute, option_name), options|
|
|
196
|
+
value = @config.public_send(config_attribute)
|
|
197
|
+
options[option_name] = normalize_duration(value) if value
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def normalize_duration(value)
|
|
202
|
+
return value.to_f if value.respond_to?(:to_f)
|
|
203
|
+
|
|
204
|
+
value
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
# rubocop:enable Metrics/ClassLength
|
|
208
|
+
end
|
|
209
|
+
end
|