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.
Files changed (99) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +130 -0
  3. data/LICENSE +21 -0
  4. data/README.md +198 -0
  5. data/activejob-temporal.gemspec +58 -0
  6. data/api/job_payload_schema.json +318 -0
  7. data/bin/temporal-worker +295 -0
  8. data/lib/activejob/temporal/active_job_handler_source.rb +84 -0
  9. data/lib/activejob/temporal/activities/aj_runner_activity.rb +454 -0
  10. data/lib/activejob/temporal/activities/best_effort_side_effects.rb +49 -0
  11. data/lib/activejob/temporal/activities/dependency_status_activity.rb +160 -0
  12. data/lib/activejob/temporal/activities/rate_limit_activity.rb +41 -0
  13. data/lib/activejob/temporal/adapter.rb +257 -0
  14. data/lib/activejob/temporal/audit_log.rb +118 -0
  15. data/lib/activejob/temporal/batch_enqueue_result.rb +110 -0
  16. data/lib/activejob/temporal/batch_enqueuer.rb +141 -0
  17. data/lib/activejob/temporal/bind_policy.rb +44 -0
  18. data/lib/activejob/temporal/cancel/batch_canceller.rb +154 -0
  19. data/lib/activejob/temporal/cancel/batch_summary.rb +45 -0
  20. data/lib/activejob/temporal/cancel.rb +236 -0
  21. data/lib/activejob/temporal/certificate_watcher.rb +76 -0
  22. data/lib/activejob/temporal/chain_options.rb +83 -0
  23. data/lib/activejob/temporal/child_workflow_options.rb +102 -0
  24. data/lib/activejob/temporal/client.rb +215 -0
  25. data/lib/activejob/temporal/conditional_enqueue.rb +56 -0
  26. data/lib/activejob/temporal/configurable.rb +55 -0
  27. data/lib/activejob/temporal/configuration.rb +981 -0
  28. data/lib/activejob/temporal/configured_job_compatibility.rb +44 -0
  29. data/lib/activejob/temporal/connection_worker_pool.rb +88 -0
  30. data/lib/activejob/temporal/dead_letter_payload_validation.rb +34 -0
  31. data/lib/activejob/temporal/dead_letter_queue.rb +163 -0
  32. data/lib/activejob/temporal/dependency_options.rb +134 -0
  33. data/lib/activejob/temporal/external_operation.rb +193 -0
  34. data/lib/activejob/temporal/health_check_server.rb +159 -0
  35. data/lib/activejob/temporal/http_line_reader.rb +36 -0
  36. data/lib/activejob/temporal/inspect.rb +184 -0
  37. data/lib/activejob/temporal/job_descriptor.rb +37 -0
  38. data/lib/activejob/temporal/job_payload_builder.rb +209 -0
  39. data/lib/activejob/temporal/job_payload_chain_builder.rb +106 -0
  40. data/lib/activejob/temporal/job_payload_child_workflows.rb +127 -0
  41. data/lib/activejob/temporal/job_payload_dependencies.rb +40 -0
  42. data/lib/activejob/temporal/job_payload_rate_limits.rb +53 -0
  43. data/lib/activejob/temporal/job_payload_workflow_interactions.rb +31 -0
  44. data/lib/activejob/temporal/job_tags.rb +40 -0
  45. data/lib/activejob/temporal/locales/en.yml +126 -0
  46. data/lib/activejob/temporal/logger.rb +214 -0
  47. data/lib/activejob/temporal/metrics_server.rb +150 -0
  48. data/lib/activejob/temporal/middleware/chain.rb +106 -0
  49. data/lib/activejob/temporal/middleware.rb +11 -0
  50. data/lib/activejob/temporal/observability/datadog.rb +167 -0
  51. data/lib/activejob/temporal/observability/opentelemetry.rb +107 -0
  52. data/lib/activejob/temporal/observability/prometheus.rb +271 -0
  53. data/lib/activejob/temporal/observability.rb +260 -0
  54. data/lib/activejob/temporal/payload.rb +415 -0
  55. data/lib/activejob/temporal/payload_encryption.rb +215 -0
  56. data/lib/activejob/temporal/payload_serializers/json.rb +23 -0
  57. data/lib/activejob/temporal/payload_serializers/marshal.rb +53 -0
  58. data/lib/activejob/temporal/payload_serializers/message_pack.rb +59 -0
  59. data/lib/activejob/temporal/payload_serializers.rb +37 -0
  60. data/lib/activejob/temporal/payload_storage.rb +103 -0
  61. data/lib/activejob/temporal/rails_environment_loader.rb +143 -0
  62. data/lib/activejob/temporal/rate_limit_options.rb +94 -0
  63. data/lib/activejob/temporal/rate_limiters/memory.rb +198 -0
  64. data/lib/activejob/temporal/reload_signal_queue.rb +40 -0
  65. data/lib/activejob/temporal/retry_handler_extractor.rb +361 -0
  66. data/lib/activejob/temporal/retry_mapper.rb +264 -0
  67. data/lib/activejob/temporal/schedulable.rb +60 -0
  68. data/lib/activejob/temporal/schedule.rb +181 -0
  69. data/lib/activejob/temporal/schedule_options.rb +105 -0
  70. data/lib/activejob/temporal/search_attributes.rb +173 -0
  71. data/lib/activejob/temporal/signal_query.rb +161 -0
  72. data/lib/activejob/temporal/signal_query_options.rb +106 -0
  73. data/lib/activejob/temporal/temporal_options.rb +114 -0
  74. data/lib/activejob/temporal/tls_file.rb +45 -0
  75. data/lib/activejob/temporal/transaction_safety.rb +39 -0
  76. data/lib/activejob/temporal/version.rb +7 -0
  77. data/lib/activejob/temporal/visibility_query.rb +13 -0
  78. data/lib/activejob/temporal/worker_client_reloader.rb +34 -0
  79. data/lib/activejob/temporal/worker_health.rb +117 -0
  80. data/lib/activejob/temporal/worker_pool.rb +408 -0
  81. data/lib/activejob/temporal/workflow_enqueuer.rb +271 -0
  82. data/lib/activejob/temporal/workflow_enqueuer_batch.rb +17 -0
  83. data/lib/activejob/temporal/workflow_id_builder.rb +155 -0
  84. data/lib/activejob/temporal/workflow_identity.rb +62 -0
  85. data/lib/activejob/temporal/workflows/aj_workflow.rb +282 -0
  86. data/lib/activejob/temporal/workflows/dead_letter_support.rb +134 -0
  87. data/lib/activejob/temporal/workflows/dead_letter_workflow.rb +114 -0
  88. data/lib/activejob/temporal/workflows/workflow_chaining.rb +194 -0
  89. data/lib/activejob/temporal/workflows/workflow_child_workflows.rb +140 -0
  90. data/lib/activejob/temporal/workflows/workflow_continue_as_new.rb +44 -0
  91. data/lib/activejob/temporal/workflows/workflow_dependencies.rb +115 -0
  92. data/lib/activejob/temporal/workflows/workflow_execution_steps.rb +22 -0
  93. data/lib/activejob/temporal/workflows/workflow_interactions.rb +215 -0
  94. data/lib/activejob/temporal/workflows/workflow_local_activities.rb +29 -0
  95. data/lib/activejob/temporal/workflows/workflow_nexus.rb +15 -0
  96. data/lib/activejob/temporal/workflows/workflow_versioning.rb +21 -0
  97. data/lib/activejob/temporal.rb +297 -0
  98. data/lib/activejob-temporal.rb +3 -0
  99. 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