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,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require "temporalio/workflow"
5
+
6
+ module ActiveJob
7
+ module Temporal
8
+ module Workflows
9
+ class DeadLetterWorkflow < Temporalio::Workflow::Definition
10
+ workflow_name :ActiveJobTemporalDeadLetterWorkflow
11
+
12
+ workflow_query_attr_reader :entry
13
+
14
+ workflow_signal
15
+ def mark_retried(retry_workflow_id)
16
+ if @entry
17
+ mark_retried_entry(retry_workflow_id)
18
+ else
19
+ @pending_action = [:retried, retry_workflow_id]
20
+ end
21
+ end
22
+
23
+ workflow_signal
24
+ def discard(reason = nil)
25
+ if @entry
26
+ mark_discarded_entry(reason)
27
+ else
28
+ @pending_action = [:discarded, reason]
29
+ end
30
+ end
31
+
32
+ def execute(entry)
33
+ @entry = deep_stringify(entry)
34
+ apply_pending_action
35
+ wait_until_terminal_or_expired
36
+ @entry
37
+ end
38
+
39
+ private
40
+
41
+ def wait_until_terminal_or_expired
42
+ auto_discard_after = auto_discard_after_seconds
43
+ return Temporalio::Workflow.wait_condition { terminal? } unless auto_discard_after
44
+
45
+ begin
46
+ Temporalio::Workflow.timeout(
47
+ auto_discard_after,
48
+ Timeout::Error,
49
+ "dead letter auto-discard expired",
50
+ summary: "Dead letter auto-discard"
51
+ ) do
52
+ Temporalio::Workflow.wait_condition { terminal? }
53
+ end
54
+ rescue Timeout::Error
55
+ mark_discarded_entry("auto_discard_after_expired")
56
+ end
57
+ end
58
+
59
+ def auto_discard_after_seconds
60
+ value = @entry.dig("metadata", "auto_discard_after_seconds")
61
+ return if value.nil?
62
+
63
+ seconds = Float(value)
64
+ seconds if seconds.positive?
65
+ rescue ArgumentError, TypeError
66
+ nil
67
+ end
68
+
69
+ def apply_pending_action
70
+ action, value = @pending_action
71
+ @pending_action = nil
72
+
73
+ case action
74
+ when :retried then mark_retried_entry(value)
75
+ when :discarded then mark_discarded_entry(value)
76
+ end
77
+ end
78
+
79
+ def mark_retried_entry(retry_workflow_id)
80
+ return if terminal?
81
+
82
+ @entry["state"] = "retried"
83
+ @entry["retry_workflow_id"] = retry_workflow_id
84
+ @entry["retried_at"] = Temporalio::Workflow.now.iso8601
85
+ end
86
+
87
+ def mark_discarded_entry(reason)
88
+ return if terminal?
89
+
90
+ @entry["state"] = "discarded"
91
+ @entry["discard_reason"] = reason if reason
92
+ @entry["discarded_at"] = Temporalio::Workflow.now.iso8601
93
+ end
94
+
95
+ def terminal?
96
+ %w[retried discarded].include?(@entry&.fetch("state", nil))
97
+ end
98
+
99
+ def deep_stringify(value)
100
+ case value
101
+ when Hash
102
+ value.each_with_object({}) do |(key, entry_value), hash|
103
+ hash[key.to_s] = deep_stringify(entry_value)
104
+ end
105
+ when Array
106
+ value.map { |entry_value| deep_stringify(entry_value) }
107
+ else
108
+ value
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../activities/aj_runner_activity"
4
+
5
+ module ActiveJob
6
+ module Temporal
7
+ module Workflows
8
+ module WorkflowChaining
9
+ WORKFLOW_CONTROL_FIELDS = %i[
10
+ default_activity_options
11
+ retry_policy
12
+ temporal_options
13
+ dead_letter
14
+ rate_limits
15
+ activity_task_queue
16
+ local_activity_helpers
17
+ ].freeze
18
+
19
+ private
20
+
21
+ def execute_activity_sequence(payload)
22
+ result = execute_activity_payload(payload)
23
+ execute_chain_sequence(payload, result) do |chain_payload|
24
+ yield chain_payload if block_given?
25
+ end
26
+ end
27
+
28
+ def execute_chain_sequence(payload, result)
29
+ chain_payloads(payload).each_with_index do |chain_payload, index|
30
+ workflow_state["chain_index"] = index + 1
31
+ if external_operation_payload?(chain_payload)
32
+ yield chain_payload if block_given?
33
+ result = execute_external_operation(chain_payload, result)
34
+ else
35
+ raw_arguments = [result]
36
+ activity_payload = payload_with_raw_arguments(chain_payload, raw_arguments)
37
+ yield activity_payload if block_given?
38
+ result = execute_activity_payload(activity_payload, raw_arguments: raw_arguments)
39
+ end
40
+ end
41
+ result
42
+ ensure
43
+ workflow_state.delete("chain_index")
44
+ end
45
+
46
+ def execute_activity_payload(payload, raw_arguments: nil)
47
+ wait_for_rate_limit(payload)
48
+ wait_while_paused
49
+
50
+ workflow_state["phase"] = "running_activity"
51
+ Temporalio::Workflow.execute_activity(
52
+ *activity_arguments(payload, raw_arguments),
53
+ **activity_options(payload)
54
+ )
55
+ end
56
+
57
+ def activity_arguments(payload, raw_arguments)
58
+ arguments = [
59
+ ActiveJob::Temporal::Activities::AjRunnerActivity,
60
+ payload
61
+ ]
62
+ arguments << raw_arguments unless raw_arguments.nil?
63
+ arguments
64
+ end
65
+
66
+ def execute_external_operation(payload, input)
67
+ case payload_value(payload, :temporal_operation)
68
+ when "activity" then execute_external_activity(payload, input)
69
+ when "workflow" then execute_external_workflow(payload, input)
70
+ else raise ArgumentError, "external Temporal operation must be activity or workflow"
71
+ end
72
+ end
73
+
74
+ def execute_external_activity(payload, input)
75
+ wait_while_paused
76
+ workflow_state["phase"] = "running_activity"
77
+ Temporalio::Workflow.execute_activity(
78
+ payload_value(payload, :temporal_type),
79
+ input,
80
+ **external_operation_options(payload)
81
+ )
82
+ end
83
+
84
+ def execute_external_workflow(payload, input)
85
+ wait_while_paused
86
+ workflow_state["phase"] = "running_child_workflow"
87
+ Temporalio::Workflow.execute_child_workflow(
88
+ payload_value(payload, :temporal_type),
89
+ input,
90
+ **external_operation_options(payload)
91
+ )
92
+ end
93
+
94
+ def chain_payloads(payload)
95
+ Array(payload[:chain] || payload["chain"]).each_with_index.map do |chain_step, index|
96
+ chain_step_payload(payload, chain_step, index + 1)
97
+ end
98
+ end
99
+
100
+ def chain_step_payload(root_payload, chain_step, position)
101
+ return normalize_external_operation_payload(chain_step) if external_operation_payload?(chain_step)
102
+ return normalize_chain_step_payload(chain_step) if full_chain_step_payload?(chain_step)
103
+
104
+ options = chain_step_options(chain_step)
105
+ payload = base_chain_step_payload(root_payload, chain_step, options, position)
106
+
107
+ copy_workflow_control_fields(root_payload, payload)
108
+ payload.merge!(options)
109
+ payload
110
+ end
111
+
112
+ def full_chain_step_payload?(chain_step)
113
+ payload_value(chain_step, :job_id) &&
114
+ payload_value(chain_step, :queue_name) &&
115
+ !payload_value(chain_step, :arguments).nil?
116
+ end
117
+
118
+ def normalize_chain_step_payload(chain_step)
119
+ chain_step.each_with_object({}) do |(key, value), normalized|
120
+ normalized[key.to_s] = value
121
+ end
122
+ end
123
+
124
+ def normalize_external_operation_payload(operation_payload)
125
+ {
126
+ "temporal_operation" => payload_value(operation_payload, :temporal_operation),
127
+ "temporal_type" => payload_value(operation_payload, :temporal_type),
128
+ "options" => external_operation_options(operation_payload)
129
+ }
130
+ end
131
+
132
+ def base_chain_step_payload(root_payload, chain_step, options, position)
133
+ {
134
+ "job_class" => payload_value(chain_step, :job_class),
135
+ "job_id" => "#{payload_value(root_payload, :job_id)}:chain:#{position}",
136
+ "queue_name" => (options["queue"] || "default").to_s,
137
+ "arguments" => [],
138
+ "executions" => 0,
139
+ "exception_executions" => {}
140
+ }
141
+ end
142
+
143
+ def chain_step_options(chain_step)
144
+ options = payload_value(chain_step, :options) || {}
145
+ options.each_with_object({}) do |(key, value), normalized|
146
+ normalized[key.to_s] = value
147
+ end
148
+ end
149
+
150
+ def copy_workflow_control_fields(source_payload, target_payload)
151
+ WORKFLOW_CONTROL_FIELDS.each do |key|
152
+ value = payload_value(source_payload, key)
153
+ next unless value
154
+
155
+ target_payload[key.to_s] = key == :dead_letter ? chain_dead_letter_metadata(value, target_payload) : value
156
+ end
157
+ end
158
+
159
+ def chain_dead_letter_metadata(metadata, payload)
160
+ metadata.transform_keys(&:to_s).merge(
161
+ "job_class" => payload.fetch("job_class"),
162
+ "job_id" => payload.fetch("job_id"),
163
+ "queue_name" => payload.fetch("queue_name")
164
+ )
165
+ end
166
+
167
+ def payload_value(payload, key)
168
+ payload[key] || payload[key.to_s]
169
+ end
170
+
171
+ def payload_with_raw_arguments(payload, raw_arguments)
172
+ key = payload.key?(:arguments) ? :arguments : "arguments"
173
+ payload.merge(key => raw_arguments)
174
+ end
175
+
176
+ def external_operation_payload?(payload)
177
+ payload_value(payload, :temporal_operation) && payload_value(payload, :temporal_type)
178
+ end
179
+
180
+ def external_operation_options(payload)
181
+ options = payload_value(payload, :options) || {}
182
+ options.each_with_object({}) do |(key, value), normalized|
183
+ normalized_key = key.to_sym
184
+ normalized[normalized_key] = if normalized_key == :retry_policy && value.is_a?(Hash)
185
+ build_retry_policy(value)
186
+ else
187
+ value
188
+ end
189
+ end
190
+ end
191
+ end
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require "temporalio/search_attributes"
5
+ require "temporalio/workflow"
6
+
7
+ require_relative "../search_attributes"
8
+
9
+ module ActiveJob
10
+ module Temporal
11
+ module Workflows
12
+ module WorkflowChildWorkflows
13
+ private
14
+
15
+ def execute_child_workflow_sequence(payload, parent_result)
16
+ child_payloads = child_workflow_payloads(payload)
17
+ return parent_result if child_payloads.empty?
18
+
19
+ workflow_state["phase"] = "running_child_workflows"
20
+ handles = start_child_job_workflows(child_payloads, parent_result)
21
+ child_results = collect_child_workflow_results(child_payloads, handles)
22
+
23
+ {
24
+ "parent_result" => parent_result,
25
+ "child_results" => child_results
26
+ }
27
+ ensure
28
+ workflow_state.delete("child_index")
29
+ end
30
+
31
+ def child_workflow_payloads(payload)
32
+ Array(payload[:child_workflows] || payload["child_workflows"])
33
+ end
34
+
35
+ def start_child_job_workflows(child_payloads, parent_result)
36
+ child_payloads.each_with_index.map do |child_payload, index|
37
+ workflow_state["child_index"] = index + 1
38
+ start_child_job_workflow(child_payload, parent_result)
39
+ end
40
+ end
41
+
42
+ def collect_child_workflow_results(child_payloads, handles)
43
+ handles.each_with_index.map do |handle, index|
44
+ workflow_state["child_index"] = index + 1
45
+ child_workflow_result(child_payloads.fetch(index), handle.result)
46
+ end
47
+ end
48
+
49
+ def start_child_job_workflow(child_payload, parent_result)
50
+ if external_operation_payload?(child_payload)
51
+ return start_external_child_workflow(child_payload, parent_result)
52
+ end
53
+
54
+ workflow_payload = child_payload_with_parent_result(child_payload, parent_result)
55
+ Temporalio::Workflow.start_child_workflow(
56
+ ActiveJob::Temporal::Workflows::AjWorkflow,
57
+ workflow_payload,
58
+ **child_workflow_options(workflow_payload)
59
+ )
60
+ end
61
+
62
+ def start_external_child_workflow(child_payload, parent_result)
63
+ Temporalio::Workflow.start_child_workflow(
64
+ payload_value(child_payload, :temporal_type),
65
+ parent_result,
66
+ **external_child_workflow_options(child_payload)
67
+ )
68
+ end
69
+
70
+ def child_workflow_options(child_payload)
71
+ task_queue = payload_value(child_payload, :workflow_task_queue) ||
72
+ payload_value(child_payload, :activity_task_queue)
73
+ options = {
74
+ id: payload_value(child_payload, :workflow_id),
75
+ task_queue: task_queue,
76
+ parent_close_policy: Temporalio::Workflow::ParentClosePolicy::REQUEST_CANCEL,
77
+ cancellation_type: Temporalio::Workflow::ChildWorkflowCancellationType::WAIT_CANCELLATION_COMPLETED
78
+ }.compact
79
+ search_attributes = child_search_attributes(payload_value(child_payload, :search_attributes))
80
+ options[:search_attributes] = search_attributes if search_attributes
81
+ options
82
+ end
83
+
84
+ def external_child_workflow_options(child_payload)
85
+ external_operation_options(child_payload).merge(
86
+ parent_close_policy: Temporalio::Workflow::ParentClosePolicy::REQUEST_CANCEL,
87
+ cancellation_type: Temporalio::Workflow::ChildWorkflowCancellationType::WAIT_CANCELLATION_COMPLETED
88
+ )
89
+ end
90
+
91
+ def child_payload_with_parent_result(child_payload, parent_result)
92
+ key = child_payload.key?(:arguments) ? :arguments : "arguments"
93
+ child_payload.merge(key => [parent_result])
94
+ end
95
+
96
+ def child_workflow_result(child_payload, result)
97
+ return external_child_workflow_result(child_payload, result) if external_operation_payload?(child_payload)
98
+
99
+ {
100
+ "job_class" => payload_value(child_payload, :job_class),
101
+ "job_id" => payload_value(child_payload, :job_id),
102
+ "workflow_id" => payload_value(child_payload, :workflow_id),
103
+ "result" => result
104
+ }
105
+ end
106
+
107
+ def external_child_workflow_result(child_payload, result)
108
+ options = payload_value(child_payload, :options) || {}
109
+ {
110
+ "temporal_operation" => "workflow",
111
+ "temporal_type" => payload_value(child_payload, :temporal_type),
112
+ "workflow_id" => payload_value(options, :id),
113
+ "task_queue" => payload_value(options, :task_queue),
114
+ "result" => result
115
+ }.compact
116
+ end
117
+
118
+ def child_search_attributes(metadata)
119
+ return unless metadata
120
+
121
+ attributes = Temporalio::SearchAttributes.new
122
+ attributes[search_attribute_key(:aj_class)] = payload_value(metadata, :job_class)
123
+ attributes[search_attribute_key(:aj_queue)] = payload_value(metadata, :queue_name) || "default"
124
+ attributes[search_attribute_key(:aj_job_id)] = payload_value(metadata, :job_id)
125
+ attributes[search_attribute_key(:aj_enqueued_at)] = Time.iso8601(payload_value(metadata, :enqueued_at))
126
+
127
+ tags = Array(payload_value(metadata, :tags))
128
+ attributes[search_attribute_key(:aj_tags)] = tags if tags.any?
129
+ attributes
130
+ end
131
+
132
+ def search_attribute_key(key)
133
+ name, type = ActiveJob::Temporal::SearchAttributes::SEARCH_ATTRIBUTE_KEY_DEFINITIONS.fetch(key)
134
+ type_constant = Temporalio::SearchAttributes::IndexedValueType.const_get(type)
135
+ Temporalio::SearchAttributes::Key.new(name, type_constant)
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveJob
4
+ module Temporal
5
+ module Workflows
6
+ module WorkflowContinueAsNew
7
+ private
8
+
9
+ def continue_as_new_if_needed(payload)
10
+ return unless workflow_patch_enabled?(:continue_as_new)
11
+
12
+ continue_as_new_options = payload[:continue_as_new] || payload["continue_as_new"]
13
+ return unless continue_as_new_options
14
+ return unless continue_as_new_threshold_reached?(continue_as_new_options)
15
+
16
+ wait_for_continue_as_new_handlers
17
+ workflow_state["phase"] = "continuing_as_new"
18
+ raise Temporalio::Workflow::ContinueAsNewError.new(
19
+ continue_as_new_payload(payload),
20
+ search_attributes: Temporalio::Workflow.search_attributes
21
+ )
22
+ end
23
+
24
+ def continue_as_new_threshold_reached?(continue_as_new_options)
25
+ threshold = continue_as_new_options[:history_event_threshold] ||
26
+ continue_as_new_options["history_event_threshold"]
27
+ return false unless threshold.to_i.positive?
28
+
29
+ Temporalio::Workflow.current_history_length >= threshold.to_i
30
+ end
31
+
32
+ def continue_as_new_payload(payload)
33
+ deep_copy(payload).merge("workflow_state" => deep_copy(workflow_state))
34
+ end
35
+
36
+ def wait_for_continue_as_new_handlers
37
+ return if Temporalio::Workflow.all_handlers_finished?
38
+
39
+ Temporalio::Workflow.wait_condition { Temporalio::Workflow.all_handlers_finished? }
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "temporalio/error"
4
+
5
+ require_relative "../activities/dependency_status_activity"
6
+
7
+ module ActiveJob
8
+ module Temporal
9
+ module Workflows
10
+ module WorkflowDependencies
11
+ DEPENDENCY_CHECK_ACTIVITY_TIMEOUT = 30.0
12
+ DEPENDENCY_CHECK_RETRY_POLICY = Temporalio::RetryPolicy.new(max_attempts: 1)
13
+ DEPENDENCY_WAIT_INTERVAL = 10.0
14
+ DEPENDENCY_NOT_FOUND_MAX_CHECKS = 6
15
+ COMPLETED_DEPENDENCY_STATES = %w[completed].freeze
16
+ FAILED_DEPENDENCY_STATES = %w[failed canceled terminated timed_out unknown].freeze
17
+ NOT_FOUND_DEPENDENCY_STATES = %w[not_found].freeze
18
+
19
+ private
20
+
21
+ def wait_for_dependencies(payload)
22
+ dependencies = dependency_metadata(payload)
23
+ return if dependencies.empty?
24
+
25
+ workflow_state["phase"] = "waiting_dependencies"
26
+ not_found_counts = Hash.new(0)
27
+ loop do
28
+ wait_while_paused
29
+ statuses = check_dependency_statuses(dependencies)
30
+ expired_not_found_statuses = expired_not_found_statuses(statuses, not_found_counts)
31
+ failed_statuses = failed_dependency_statuses(statuses) + expired_not_found_statuses
32
+ fail_for_dependencies!(failed_statuses) if fail_on_dependency_failure?(payload) && failed_statuses.any?
33
+ break if dependencies_satisfied?(statuses, expired_not_found_statuses)
34
+
35
+ Temporalio::Workflow.sleep(DEPENDENCY_WAIT_INTERVAL)
36
+ end
37
+ end
38
+
39
+ def dependency_metadata(payload)
40
+ Array(payload[:dependencies] || payload["dependencies"])
41
+ end
42
+
43
+ def check_dependency_statuses(dependencies)
44
+ Temporalio::Workflow.execute_activity(
45
+ ActiveJob::Temporal::Activities::DependencyStatusActivity,
46
+ dependencies,
47
+ schedule_to_close_timeout: DEPENDENCY_CHECK_ACTIVITY_TIMEOUT,
48
+ start_to_close_timeout: DEPENDENCY_CHECK_ACTIVITY_TIMEOUT,
49
+ retry_policy: DEPENDENCY_CHECK_RETRY_POLICY
50
+ )
51
+ end
52
+
53
+ def dependencies_satisfied?(statuses, expired_not_found_statuses)
54
+ expired_not_found_keys = expired_not_found_statuses.map { |status| dependency_status_key(status) }
55
+ statuses.all? do |status|
56
+ state = dependency_state(status)
57
+ COMPLETED_DEPENDENCY_STATES.include?(state) ||
58
+ FAILED_DEPENDENCY_STATES.include?(state) ||
59
+ expired_not_found_keys.include?(dependency_status_key(status))
60
+ end
61
+ end
62
+
63
+ def expired_not_found_statuses(statuses, not_found_counts)
64
+ statuses.each_with_object([]) do |status, expired_statuses|
65
+ key = dependency_status_key(status)
66
+ if NOT_FOUND_DEPENDENCY_STATES.include?(dependency_state(status))
67
+ not_found_counts[key] += 1
68
+ expired_statuses << status if not_found_counts[key] >= DEPENDENCY_NOT_FOUND_MAX_CHECKS
69
+ else
70
+ not_found_counts.delete(key)
71
+ end
72
+ end
73
+ end
74
+
75
+ def failed_dependency_statuses(statuses)
76
+ statuses.select do |status|
77
+ FAILED_DEPENDENCY_STATES.include?(dependency_state(status))
78
+ end
79
+ end
80
+
81
+ def dependency_state(status)
82
+ status["state"] || status[:state]
83
+ end
84
+
85
+ def dependency_status_key(status)
86
+ job_class = status["job_class"] || status[:job_class]
87
+ job_id = status["job_id"] || status[:job_id]
88
+ return "#{job_class}:#{job_id}" if job_class && job_id
89
+ return job_id if job_id
90
+
91
+ status["workflow_id"] || status[:workflow_id] || status.hash
92
+ end
93
+
94
+ def fail_on_dependency_failure?(payload)
95
+ policy = payload[:dependency_failure_policy] || payload["dependency_failure_policy"] || "fail"
96
+ policy.to_s == "fail"
97
+ end
98
+
99
+ def fail_for_dependencies!(statuses)
100
+ descriptions = statuses.map do |status|
101
+ identifier = status["workflow_id"] || status[:workflow_id] || status["job_id"] || status[:job_id]
102
+ state = status["state"] || status[:state]
103
+ "#{identifier}: #{state}"
104
+ end
105
+
106
+ raise Temporalio::Error::ApplicationError.new(
107
+ "Job dependency failed: #{descriptions.join(', ')}",
108
+ type: "ActiveJob::Temporal::DependencyFailed",
109
+ non_retryable: true
110
+ )
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveJob
4
+ module Temporal
5
+ module Workflows
6
+ module WorkflowExecutionSteps
7
+ private
8
+
9
+ def execute_workflow_steps(payload, &)
10
+ continue_as_new_if_needed(payload)
11
+ wait_until_scheduled(payload)
12
+ continue_as_new_if_needed(payload)
13
+ wait_for_dependencies(payload)
14
+ continue_as_new_if_needed(payload)
15
+ result = execute_activity_payload(payload)
16
+ result = execute_child_workflow_sequence(payload, result)
17
+ execute_chain_sequence(payload, result, &)
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end