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,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "configuration"
4
+
5
+ module ActiveJob
6
+ module Temporal
7
+ # Builds deterministic Temporal workflow IDs for ActiveJob jobs.
8
+ #
9
+ # The default format keeps workflow IDs stable across enqueue retries so
10
+ # Temporal can reject duplicate starts for the same ActiveJob job_id.
11
+ class WorkflowIdBuilder
12
+ DEFAULT_PREFIX = "ajwf"
13
+ MAX_WORKFLOW_ID_LENGTH = 255
14
+ CONTROL_CHARACTER_PATTERN = /[[:cntrl:]]/
15
+
16
+ # @param strategy [#call, nil] Optional callable that receives the job and returns a workflow ID
17
+ def initialize(strategy = nil)
18
+ @strategy = strategy
19
+ end
20
+
21
+ # Builds a workflow ID from an ActiveJob instance.
22
+ #
23
+ # @param job [ActiveJob::Base] ActiveJob instance being enqueued
24
+ # @return [String] Workflow ID in format "ajwf:<ClassName>:<job_id>"
25
+ def build(job)
26
+ workflow_id = class_configured_workflow_id(job) ||
27
+ configured_workflow_id(job) ||
28
+ self.class.default_for(job)
29
+ self.class.validate!(workflow_id)
30
+ workflow_id
31
+ end
32
+
33
+ # Builds a workflow ID from a job class and job ID.
34
+ #
35
+ # @param job_class [Class] ActiveJob class
36
+ # @param job_id [String] ActiveJob job_id
37
+ # @return [String] Workflow ID in format "ajwf:<ClassName>:<job_id>"
38
+ def build_from_job_class(job_class, job_id)
39
+ workflow_id = self.class.prefixed_from_job_class(job_class, job_id) ||
40
+ self.class.default_from_job_class(job_class, job_id)
41
+ self.class.validate!(workflow_id)
42
+ workflow_id
43
+ end
44
+
45
+ class << self
46
+ # Builds the default workflow ID for a job.
47
+ #
48
+ # @param job [ActiveJob::Base] ActiveJob instance being enqueued
49
+ # @return [String] Workflow ID in format "ajwf:<ClassName>:<job_id>"
50
+ def default_for(job)
51
+ default_from_job_class(job.class, job.job_id)
52
+ end
53
+
54
+ # Builds the default workflow ID from a job class and job ID.
55
+ #
56
+ # @param job_class [Class] ActiveJob class
57
+ # @param job_id [String] ActiveJob job_id
58
+ # @return [String] Workflow ID in format "ajwf:<ClassName>:<job_id>"
59
+ def default_from_job_class(job_class, job_id)
60
+ "#{DEFAULT_PREFIX}:#{job_class.name}:#{job_id}"
61
+ end
62
+
63
+ def prefixed_from_job_class(job_class, job_id)
64
+ prefix = workflow_id_prefix_for(job_class)
65
+ return unless prefix
66
+
67
+ "#{prefix}:#{job_id}"
68
+ end
69
+
70
+ def workflow_id_prefix_for(job_class)
71
+ return unless job_class.respond_to?(:temporal_workflow_id_prefix)
72
+
73
+ job_class.temporal_workflow_id_prefix
74
+ end
75
+
76
+ # Validates generated workflow IDs before they reach Temporal.
77
+ #
78
+ # @param workflow_id [Object] generated workflow ID
79
+ # @return [void]
80
+ # @raise [ConfigurationError] when the generated ID is invalid
81
+ def validate!(workflow_id)
82
+ unless workflow_id.is_a?(String)
83
+ raise ConfigurationError,
84
+ "workflow_id_generator must return a String, got #{workflow_id.class}: #{workflow_id.inspect}"
85
+ end
86
+
87
+ if workflow_id.empty?
88
+ raise ConfigurationError, "workflow_id_generator returned an invalid workflow ID: must not be blank"
89
+ end
90
+
91
+ unless utf8_compatible?(workflow_id)
92
+ raise ConfigurationError,
93
+ "workflow_id_generator returned an invalid workflow ID: must be valid UTF-8"
94
+ end
95
+
96
+ if workflow_id.length > MAX_WORKFLOW_ID_LENGTH
97
+ raise ConfigurationError,
98
+ "workflow_id_generator returned an invalid workflow ID: maximum length is " \
99
+ "#{MAX_WORKFLOW_ID_LENGTH} characters (got #{workflow_id.length})"
100
+ end
101
+
102
+ return unless workflow_id.match?(CONTROL_CHARACTER_PATTERN)
103
+
104
+ raise ConfigurationError,
105
+ "workflow_id_generator returned an invalid workflow ID: control characters are not allowed " \
106
+ "(got #{workflow_id.inspect})"
107
+ end
108
+
109
+ private
110
+
111
+ def utf8_compatible?(workflow_id)
112
+ workflow_id.valid_encoding? && (workflow_id.encoding == Encoding::UTF_8 || workflow_id.ascii_only?)
113
+ end
114
+ end
115
+
116
+ private
117
+
118
+ def class_configured_workflow_id(job)
119
+ prefix = self.class.workflow_id_prefix_for(job.class)
120
+ return "#{prefix}:#{job.job_id}" if prefix
121
+
122
+ strategy = class_workflow_id_strategy(job.class)
123
+ return unless strategy
124
+
125
+ call_class_strategy(strategy, job)
126
+ end
127
+
128
+ def class_workflow_id_strategy(job_class)
129
+ return unless job_class.respond_to?(:temporal_workflow_id)
130
+
131
+ job_class.temporal_workflow_id
132
+ end
133
+
134
+ def call_class_strategy(strategy, job)
135
+ strategy.call(*Array(job.arguments))
136
+ rescue ArgumentError => e
137
+ raise ConfigurationError,
138
+ "temporal_workflow_id block must accept this job's perform arguments: #{e.message}"
139
+ end
140
+
141
+ def configured_workflow_id(job)
142
+ return unless @strategy
143
+
144
+ call_strategy(job)
145
+ end
146
+
147
+ def call_strategy(job)
148
+ @strategy.call(job)
149
+ rescue ArgumentError => e
150
+ raise ConfigurationError,
151
+ "workflow_id_generator must accept one positional ActiveJob argument: #{e.message}"
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job"
4
+ require "active_support/concern"
5
+
6
+ module ActiveJob
7
+ module Temporal
8
+ module WorkflowIdentity
9
+ extend ActiveSupport::Concern
10
+
11
+ UNSET = Object.new.freeze
12
+ CONTROL_CHARACTER_PATTERN = /[[:cntrl:]]/
13
+
14
+ def self.normalize_identity_value(value, label)
15
+ raise ArgumentError, "#{label} must be a String" unless value.is_a?(String)
16
+
17
+ normalized = value.strip
18
+ raise ArgumentError, "#{label} must be present" if normalized.empty?
19
+ raise ArgumentError, "#{label} must be valid UTF-8" unless normalized.valid_encoding?
20
+
21
+ if normalized.match?(CONTROL_CHARACTER_PATTERN)
22
+ raise ArgumentError, "#{label} cannot contain control characters"
23
+ end
24
+
25
+ normalized
26
+ end
27
+
28
+ class_methods do
29
+ def temporal_workflow_name(name = UNSET)
30
+ unless name.equal?(UNSET)
31
+ @temporal_workflow_name = WorkflowIdentity.normalize_identity_value(name, "temporal_workflow_name")
32
+ end
33
+
34
+ @temporal_workflow_name
35
+ end
36
+
37
+ def temporal_workflow_id(&block)
38
+ if block
39
+ @temporal_workflow_id_strategy = block
40
+ @temporal_workflow_id_prefix = nil
41
+ end
42
+
43
+ @temporal_workflow_id_strategy
44
+ end
45
+
46
+ def temporal_workflow_id_prefix(prefix = UNSET)
47
+ unless prefix.equal?(UNSET)
48
+ @temporal_workflow_id_prefix = WorkflowIdentity.normalize_identity_value(
49
+ prefix,
50
+ "temporal_workflow_id_prefix"
51
+ )
52
+ @temporal_workflow_id_strategy = nil
53
+ end
54
+
55
+ @temporal_workflow_id_prefix
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+
62
+ ActiveJob::Base.include(ActiveJob::Temporal::WorkflowIdentity) if defined?(ActiveJob::Base)
@@ -0,0 +1,282 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require "active_support/core_ext/hash/keys"
5
+ require "temporalio/error"
6
+ require "temporalio/retry_policy"
7
+ require "temporalio/workflow"
8
+
9
+ require_relative "../activities/rate_limit_activity"
10
+ require_relative "dead_letter_support"
11
+ require_relative "workflow_continue_as_new"
12
+ require_relative "workflow_child_workflows"
13
+ require_relative "workflow_chaining"
14
+ require_relative "workflow_dependencies"
15
+ require_relative "workflow_execution_steps"
16
+ require_relative "workflow_interactions"
17
+ require_relative "workflow_local_activities"
18
+ require_relative "workflow_nexus"
19
+ require_relative "workflow_versioning"
20
+
21
+ module ActiveJob
22
+ module Temporal
23
+ module Workflows
24
+ RETRY_POLICY_MAX_ATTEMPT_KEYS = [:maximum_attempts, "maximum_attempts", :max_attempts, "max_attempts"].freeze
25
+
26
+ # Deterministic orchestration workflow for ActiveJob execution.
27
+ #
28
+ # This workflow serves as the durable scheduling and orchestration layer for ActiveJob.
29
+ # It handles delayed execution (via `Workflow.sleep`) and invokes the activity that
30
+ # executes the actual job logic.
31
+ #
32
+ # @note Workflow Determinism
33
+ # This workflow MUST remain deterministic. It contains no I/O operations,
34
+ # no random number generation, no system time calls (only `Workflow.now`),
35
+ # and no direct method calls to external services. All side effects occur
36
+ # in the activity layer. Temporal replays workflow code on every restart,
37
+ # so non-deterministic changes will cause workflow execution errors.
38
+ #
39
+ # @note Non-Blocking Sleep
40
+ # The `Workflow.sleep` method uses Temporal's durable timer mechanism. This means
41
+ # scheduled jobs do not consume worker resources while waiting. The workflow is
42
+ # persisted, and Temporal wakes it up at the scheduled time.
43
+ #
44
+ # @example Workflow execution flow
45
+ # 1. Extract scheduled_at timestamp from payload
46
+ # 2. If scheduled_at is in the future, sleep until that time (non-blocking)
47
+ # 3. Execute AjRunnerActivity with the payload and retry policy
48
+ # 4. Return activity result
49
+ #
50
+ # @example Replay behavior
51
+ # # If a worker crashes during step 3, Temporal will replay the workflow:
52
+ # # - Step 1: Re-reads scheduled_at (deterministic)
53
+ # # - Step 2: Skips sleep (already elapsed, replayed from history)
54
+ # # - Step 3: Continues from last checkpoint
55
+ #
56
+ # @see https://docs.temporal.io/workflows#deterministic-constraints Temporal Determinism Guide
57
+ # @see https://docs.temporal.io/workflows#timers Temporal Durable Timers
58
+ class AjWorkflow < Temporalio::Workflow::Definition
59
+ include DeadLetterSupport
60
+ include WorkflowContinueAsNew
61
+ include WorkflowChildWorkflows
62
+ include WorkflowChaining
63
+ include WorkflowDependencies
64
+ include WorkflowExecutionSteps
65
+ include WorkflowInteractions
66
+ include WorkflowLocalActivities
67
+ include WorkflowNexus
68
+ include WorkflowVersioning
69
+
70
+ DEFAULT_START_TO_CLOSE_TIMEOUT = 900.0
71
+ RATE_LIMIT_ACTIVITY_TIMEOUT = 30.0
72
+ RATE_LIMIT_RETRY_POLICY = Temporalio::RetryPolicy.new(max_attempts: 1)
73
+
74
+ workflow_signal dynamic: true
75
+ def handle_dynamic_signal(signal_name, *args)
76
+ handler_name = normalize_handler_name!(signal_name, "signal")
77
+
78
+ case handler_name
79
+ when "pause" then pause_workflow(args)
80
+ when "resume" then resume_workflow(args)
81
+ else dispatch_custom_signal(handler_name, args)
82
+ end
83
+ end
84
+
85
+ workflow_query dynamic: true
86
+ def handle_dynamic_query(query_name, *args)
87
+ handler_name = normalize_handler_name!(query_name, "query")
88
+
89
+ case handler_name
90
+ when "state" then deep_copy(workflow_state)
91
+ when "paused" then workflow_state["paused"]
92
+ when "pause_reason" then workflow_state["pause_reason"]
93
+ when "phase" then workflow_state["phase"]
94
+ when "signals" then deep_copy(workflow_state["signals"])
95
+ else dispatch_custom_query(handler_name, args)
96
+ end
97
+ end
98
+
99
+ workflow_update dynamic: true
100
+ def handle_dynamic_update(update_name, *args)
101
+ handler_name = normalize_handler_name!(update_name, "update")
102
+
103
+ dispatch_custom_update(handler_name, args)
104
+ end
105
+
106
+ # Executes the workflow: optionally sleeps until scheduled time, then runs the activity.
107
+ #
108
+ # @param payload [Hash] Job payload containing execution metadata
109
+ # @option payload [String] :job_class Fully-qualified job class name (required)
110
+ # @option payload [String] :job_id Unique job identifier (required)
111
+ # @option payload [String] :queue_name Target queue name (required)
112
+ # @option payload [Array] :arguments Serialized job arguments (required)
113
+ # @option payload [Hash] :default_activity_options Global activity timeout defaults (required)
114
+ # @option payload [Hash] :retry_policy Retry policy for activity execution (required)
115
+ # @option payload [Hash] :temporal_options Per-job timeout configuration (optional)
116
+ # @option payload [String] :scheduled_at ISO8601 timestamp for delayed execution (optional)
117
+ # @option payload [Integer] :executions Current execution count (default: 0)
118
+ # @option payload [Hash] :exception_executions Exception execution counts (default: {})
119
+ #
120
+ # @return [Object, nil] Result from the activity execution
121
+ #
122
+ # @raise [Temporalio::Error::ActivityError] if activity execution fails (propagates from activity)
123
+ # @raise [Temporalio::Error::TimeoutError] if activity exceeds start_to_close_timeout
124
+ #
125
+ # @note Durable Timers
126
+ # If scheduled_at is in the future, the workflow creates a durable timer.
127
+ # The timer persists across worker restarts and does not block worker threads.
128
+ #
129
+ # @example Immediate execution
130
+ # execute({ job_class: "MyJob", job_id: "123", arguments: ["arg1"] })
131
+ #
132
+ # @example Scheduled execution (non-blocking sleep)
133
+ # execute({
134
+ # job_class: "MyJob",
135
+ # job_id: "123",
136
+ # scheduled_at: "2025-10-31T12:00:00Z",
137
+ # arguments: []
138
+ # })
139
+ # # Workflow sleeps until scheduled time without consuming worker resources
140
+ #
141
+ # @example Replay behavior on worker restart
142
+ # # Initial execution: Workflow sleeps for 1 hour
143
+ # # Worker crashes after 30 minutes
144
+ # # Workflow is replayed: Sleep is skipped (already elapsed), activity executes immediately
145
+ #
146
+ # @note Durable Timer Guarantees
147
+ # Temporal's durable timers persist across worker restarts and cluster outages.
148
+ # Even if all workers are down, the scheduled job will execute once workers
149
+ # are back online. The timer is stored in Temporal's event history, making it
150
+ # highly reliable for long-term scheduling.
151
+ #
152
+ # @see Activities::AjRunnerActivity#execute
153
+ def execute(payload)
154
+ payload = schedule_execution_payload(payload)
155
+ current_activity_payload = payload
156
+ configure_workflow_state(payload)
157
+ configure_workflow_interactions(payload)
158
+
159
+ result = execute_workflow_steps(payload) do |chain_payload|
160
+ current_activity_payload = chain_payload
161
+ end
162
+
163
+ workflow_state["phase"] = "completed"
164
+ result
165
+ rescue Temporalio::Error::ActivityError => e
166
+ workflow_state["phase"] = "failed"
167
+ if dead_letterable_failure?(current_activity_payload, e)
168
+ start_dead_letter_workflow(current_activity_payload, e)
169
+ end
170
+ raise
171
+ end
172
+
173
+ private
174
+
175
+ def schedule_execution_payload(payload)
176
+ return payload unless payload_value(payload, :schedule_id)
177
+
178
+ execution_job_id = Temporalio::Workflow.info.workflow_id
179
+ payload.merge(
180
+ "job_id" => execution_job_id,
181
+ "schedule_execution_job_id" => execution_job_id
182
+ )
183
+ end
184
+
185
+ # Extracts scheduled execution time from payload.
186
+ # @api private
187
+ def extract_scheduled_time(payload)
188
+ timestamp = payload[:scheduled_at] || payload["scheduled_at"]
189
+ return unless timestamp
190
+
191
+ Time.iso8601(timestamp)
192
+ end
193
+
194
+ def wait_until_scheduled(payload)
195
+ scheduled_time = extract_scheduled_time(payload)
196
+ workflow_state["phase"] = "scheduled" if scheduled_time
197
+ sleep_until(scheduled_time) if scheduled_time
198
+ wait_while_paused
199
+ end
200
+
201
+ # Sleeps until target time using Temporal's durable timer.
202
+ # @api private
203
+ def sleep_until(target_time)
204
+ now = Temporalio::Workflow.now
205
+ delay = target_time - now
206
+ return unless delay.positive?
207
+
208
+ Temporalio::Workflow.sleep(delay)
209
+ end
210
+
211
+ def wait_for_rate_limit(payload)
212
+ return unless rate_limits?(payload)
213
+
214
+ workflow_state["phase"] = "waiting_rate_limit"
215
+ loop do
216
+ wait_while_paused
217
+ wait_time = execute_helper_activity(
218
+ payload,
219
+ :rate_limit,
220
+ ActiveJob::Temporal::Activities::RateLimitActivity,
221
+ payload,
222
+ schedule_to_close_timeout: RATE_LIMIT_ACTIVITY_TIMEOUT,
223
+ start_to_close_timeout: RATE_LIMIT_ACTIVITY_TIMEOUT,
224
+ retry_policy: RATE_LIMIT_RETRY_POLICY
225
+ ).to_f
226
+ break unless wait_time.positive?
227
+
228
+ Temporalio::Workflow.sleep(wait_time)
229
+ end
230
+ end
231
+
232
+ def rate_limits?(payload)
233
+ Array(payload[:rate_limits] || payload["rate_limits"]).any?
234
+ end
235
+
236
+ # Builds activity execution options with timeout configuration and retry policy.
237
+ #
238
+ # Merges timeout options from deterministic payload values only.
239
+ #
240
+ # @api private
241
+ def activity_options(payload)
242
+ options = default_activity_options(payload)
243
+
244
+ temporal_opts = payload[:temporal_options] || payload["temporal_options"]
245
+ options.merge!(temporal_opts.symbolize_keys) if temporal_opts
246
+
247
+ retry_policy_hash = payload[:retry_policy] || payload["retry_policy"]
248
+ options[:retry_policy] = build_retry_policy(retry_policy_hash) if retry_policy_hash
249
+ task_queue = payload[:activity_task_queue] || payload["activity_task_queue"]
250
+ options[:task_queue] = task_queue if task_queue
251
+
252
+ options
253
+ end
254
+
255
+ # Builds activity timeout defaults from workflow input.
256
+ # @api private
257
+ def default_activity_options(payload)
258
+ options = payload[:default_activity_options] || payload["default_activity_options"]
259
+ return options.symbolize_keys if options
260
+
261
+ { start_to_close_timeout: DEFAULT_START_TO_CLOSE_TIMEOUT }
262
+ end
263
+
264
+ # Builds Temporalio::RetryPolicy from hash.
265
+ # @api private
266
+ def build_retry_policy(hash)
267
+ # RetryMapper returns maximum_attempts, but Temporalio::RetryPolicy expects max_attempts
268
+ max_attempts_value = RETRY_POLICY_MAX_ATTEMPT_KEYS.filter_map { |key| hash[key] }.first
269
+ retry_policy_options = {
270
+ initial_interval: hash[:initial_interval] || hash["initial_interval"],
271
+ backoff_coefficient: hash[:backoff_coefficient] || hash["backoff_coefficient"],
272
+ max_interval: hash[:max_interval] || hash["max_interval"],
273
+ max_attempts: max_attempts_value,
274
+ non_retryable_error_types: hash[:non_retryable_error_types] || hash["non_retryable_error_types"]
275
+ }.compact
276
+
277
+ Temporalio::RetryPolicy.new(**retry_policy_options)
278
+ end
279
+ end
280
+ end
281
+ end
282
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "temporalio/error"
5
+ require "temporalio/workflow"
6
+
7
+ module ActiveJob
8
+ module Temporal
9
+ module Workflows
10
+ module DeadLetterSupport
11
+ private
12
+
13
+ def dead_letterable_failure?(payload, error)
14
+ metadata = dead_letter_metadata(payload)
15
+ return false unless metadata
16
+ return false unless job_execution_activity_failure?(error)
17
+
18
+ return false unless error.retry_state == Temporalio::Error::RetryState::MAXIMUM_ATTEMPTS_REACHED
19
+ return true if dead_letter_queue_present?(metadata)
20
+
21
+ log_dead_letter_skipped(metadata, error)
22
+ false
23
+ end
24
+
25
+ def start_dead_letter_workflow(payload, error)
26
+ metadata = dead_letter_metadata(payload)
27
+ entry = dead_letter_entry(payload, error, metadata)
28
+
29
+ Temporalio::Workflow.start_child_workflow(
30
+ ActiveJob::Temporal::Workflows::DeadLetterWorkflow,
31
+ entry,
32
+ id: entry.fetch("id"),
33
+ task_queue: metadata_value(metadata, :queue),
34
+ parent_close_policy: Temporalio::Workflow::ParentClosePolicy::ABANDON
35
+ )
36
+ end
37
+
38
+ def dead_letter_entry(payload, error, metadata)
39
+ {
40
+ "id" => dead_letter_workflow_id(metadata),
41
+ "state" => "pending",
42
+ "payload" => payload,
43
+ "failure" => failure_metadata(error),
44
+ "metadata" => dead_letter_failure_metadata(metadata)
45
+ }
46
+ end
47
+
48
+ def dead_letter_failure_metadata(metadata)
49
+ job_class = metadata_value(metadata, :job_class)
50
+ job_id = metadata_value(metadata, :job_id)
51
+ {
52
+ "job_class" => job_class,
53
+ "job_id" => job_id,
54
+ "original_queue_name" => metadata_value(metadata, :queue_name),
55
+ "original_task_queue" => metadata_value(metadata, :task_queue) || metadata_value(metadata, :queue_name),
56
+ "workflow_id" => workflow_id(job_class, job_id),
57
+ "workflow_run_id" => workflow_run_id,
58
+ "attempt" => metadata_value(metadata, :after_attempts),
59
+ "max_attempts" => metadata_value(metadata, :after_attempts),
60
+ "auto_discard_after_seconds" => metadata_value(metadata, :auto_discard_after_seconds),
61
+ "failed_at" => Temporalio::Workflow.now.iso8601
62
+ }.compact
63
+ end
64
+
65
+ def failure_metadata(error)
66
+ source_error = error.cause || error
67
+ class_name = failure_class_name(source_error)
68
+ {
69
+ "class" => class_name,
70
+ "message" => source_error.message.to_s,
71
+ "retry_state" => error.retry_state,
72
+ "fingerprint" => Digest::SHA256.hexdigest("#{class_name}:#{source_error.message}")
73
+ }
74
+ end
75
+
76
+ def failure_class_name(error)
77
+ type = error.type if error.is_a?(Temporalio::Error::ApplicationError)
78
+ return type unless type.to_s.strip.empty?
79
+
80
+ error.class.name
81
+ end
82
+
83
+ def dead_letter_workflow_id(metadata)
84
+ "ajdlq:#{metadata_value(metadata, :job_class)}:#{metadata_value(metadata, :job_id)}"
85
+ end
86
+
87
+ def workflow_id(job_class, job_id)
88
+ info = Temporalio::Workflow.info
89
+ info.workflow_id if info.respond_to?(:workflow_id)
90
+ rescue StandardError
91
+ "ajwf:#{job_class}:#{job_id}"
92
+ end
93
+
94
+ def workflow_run_id
95
+ info = Temporalio::Workflow.info
96
+ info.run_id if info.respond_to?(:run_id)
97
+ rescue StandardError
98
+ nil
99
+ end
100
+
101
+ def dead_letter_metadata(payload)
102
+ payload[:dead_letter] || payload["dead_letter"]
103
+ end
104
+
105
+ def job_execution_activity_failure?(error)
106
+ error.respond_to?(:activity_type) && error.activity_type == "AjRunnerActivity"
107
+ end
108
+
109
+ def dead_letter_queue_present?(metadata)
110
+ metadata_value(metadata, :queue).to_s.strip.present?
111
+ end
112
+
113
+ def log_dead_letter_skipped(metadata, error)
114
+ Temporalio::Workflow.logger.warn(
115
+ event: "dead_letter_skipped",
116
+ reason: "blank_queue",
117
+ job_class: metadata_value(metadata, :job_class),
118
+ job_id: metadata_value(metadata, :job_id),
119
+ queue_name: metadata_value(metadata, :queue_name),
120
+ retry_state: error.retry_state
121
+ )
122
+ end
123
+
124
+ def metadata_value(metadata, key)
125
+ return unless metadata.respond_to?(:[])
126
+
127
+ metadata[key] || metadata[key.to_s]
128
+ rescue TypeError
129
+ nil
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end