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,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "temporalio/activity"
4
+ require "temporalio/client/workflow_execution_status"
5
+ require "temporalio/error"
6
+
7
+ require_relative "../workflow_id_builder"
8
+
9
+ module ActiveJob
10
+ module Temporal
11
+ module Activities
12
+ class DependencyStatusActivity < Temporalio::Activity::Definition
13
+ SAFE_QUERY_VALUE_PATTERN = /\A[A-Za-z0-9_.:-]+\z/
14
+ WORKFLOW_STATES = {
15
+ Temporalio::Client::WorkflowExecutionStatus::RUNNING => "running",
16
+ Temporalio::Client::WorkflowExecutionStatus::COMPLETED => "completed",
17
+ Temporalio::Client::WorkflowExecutionStatus::FAILED => "failed",
18
+ Temporalio::Client::WorkflowExecutionStatus::CANCELED => "canceled",
19
+ Temporalio::Client::WorkflowExecutionStatus::TERMINATED => "terminated",
20
+ Temporalio::Client::WorkflowExecutionStatus::CONTINUED_AS_NEW => "continued_as_new",
21
+ Temporalio::Client::WorkflowExecutionStatus::TIMED_OUT => "timed_out"
22
+ }.freeze
23
+
24
+ def execute(dependencies)
25
+ Array(dependencies).map { |dependency| status_for(normalize_dependency(dependency)) }
26
+ end
27
+
28
+ private
29
+
30
+ def status_for(dependency)
31
+ workflow_reference = find_workflow_reference(dependency)
32
+ return status_payload(dependency, "not_found") unless workflow_reference
33
+
34
+ status_from_description(dependency, describe_workflow(workflow_reference))
35
+ rescue StandardError => e
36
+ fallback_description = describe_search_attribute_workflow(dependency) if rpc_not_found?(e)
37
+ return status_from_description(dependency, fallback_description) if fallback_description
38
+ return status_payload(dependency, "not_found") if rpc_not_found?(e) || rpc_invalid_argument?(e)
39
+
40
+ raise
41
+ end
42
+
43
+ def status_from_description(dependency, description)
44
+ status_payload(
45
+ dependency,
46
+ WORKFLOW_STATES.fetch(description.status, "unknown"),
47
+ workflow_id: description.id,
48
+ run_id: description.run_id
49
+ )
50
+ end
51
+
52
+ def normalize_dependency(dependency)
53
+ dependency.each_with_object({}) do |(key, value), normalized|
54
+ normalized[key.to_s] = value
55
+ end
56
+ end
57
+
58
+ def find_workflow_reference(dependency)
59
+ workflow_id = dependency["workflow_id"]
60
+ return { workflow_id: workflow_id, run_id: nil } if workflow_id
61
+
62
+ search_workflow_reference(dependency) || default_workflow_reference(dependency)
63
+ end
64
+
65
+ def search_workflow_reference(dependency)
66
+ job_id = dependency["job_id"]
67
+ return unless job_id
68
+
69
+ workflow = client.list_workflows(workflow_search_query(dependency)).first
70
+ return unless workflow
71
+
72
+ {
73
+ workflow_id: workflow.id,
74
+ run_id: workflow.respond_to?(:run_id) ? workflow.run_id : nil
75
+ }
76
+ rescue StandardError => e
77
+ return nil if rpc_invalid_argument?(e)
78
+
79
+ raise
80
+ end
81
+
82
+ def default_workflow_reference(dependency)
83
+ job_class = dependency["job_class"]
84
+ job_id = dependency["job_id"]
85
+ return unless job_class && job_id
86
+
87
+ workflow_id = "#{WorkflowIdBuilder::DEFAULT_PREFIX}:#{job_class}:#{job_id}"
88
+ WorkflowIdBuilder.validate!(workflow_id)
89
+ {
90
+ workflow_id: workflow_id,
91
+ run_id: nil
92
+ }
93
+ end
94
+
95
+ def workflow_search_query(dependency)
96
+ filters = ["ajJobId='#{safe_query_value(dependency.fetch('job_id'))}'"]
97
+ job_class = dependency["job_class"]
98
+ filters.unshift("ajClass='#{safe_query_value(job_class)}'") if job_class
99
+ filters.join(" AND ")
100
+ end
101
+
102
+ def safe_query_value(value)
103
+ string_value = value.to_s
104
+ return string_value if string_value.match?(SAFE_QUERY_VALUE_PATTERN)
105
+
106
+ raise ArgumentError,
107
+ "dependency query values may only contain letters, numbers, underscore, hyphen, period, and colon"
108
+ end
109
+
110
+ def describe_workflow(workflow_reference)
111
+ client.workflow_handle(
112
+ workflow_reference.fetch(:workflow_id),
113
+ run_id: workflow_reference[:run_id]
114
+ ).describe
115
+ end
116
+
117
+ def describe_search_attribute_workflow(dependency)
118
+ return unless dependency["workflow_id"] && dependency["job_id"]
119
+
120
+ fallback_dependency = dependency.dup
121
+ fallback_dependency.delete("workflow_id")
122
+ workflow_reference = search_workflow_reference(fallback_dependency)
123
+ return unless workflow_reference
124
+
125
+ describe_workflow(workflow_reference)
126
+ rescue StandardError => e
127
+ return nil if rpc_not_found?(e) || rpc_invalid_argument?(e)
128
+
129
+ raise
130
+ end
131
+
132
+ def status_payload(dependency, state, workflow_id: nil, run_id: nil)
133
+ {
134
+ "job_id" => dependency["job_id"],
135
+ "job_class" => dependency["job_class"],
136
+ "workflow_id" => workflow_id || dependency["workflow_id"],
137
+ "run_id" => run_id,
138
+ "state" => state
139
+ }.compact
140
+ end
141
+
142
+ def client
143
+ ActiveJob::Temporal.client
144
+ end
145
+
146
+ def rpc_not_found?(error)
147
+ defined?(Temporalio::Error::RPCError) &&
148
+ error.is_a?(Temporalio::Error::RPCError) &&
149
+ error.code == Temporalio::Error::RPCError::Code::NOT_FOUND
150
+ end
151
+
152
+ def rpc_invalid_argument?(error)
153
+ defined?(Temporalio::Error::RPCError) &&
154
+ error.is_a?(Temporalio::Error::RPCError) &&
155
+ error.code == Temporalio::Error::RPCError::Code::INVALID_ARGUMENT
156
+ end
157
+ end
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "temporalio/activity"
4
+
5
+ module ActiveJob
6
+ module Temporal
7
+ module Activities
8
+ # Activity wrapper around user-configured rate limiter I/O.
9
+ class RateLimitActivity < Temporalio::Activity::Definition
10
+ def execute(payload)
11
+ rate_limits = payload[:rate_limits] || payload["rate_limits"]
12
+ return 0.0 if Array(rate_limits).empty?
13
+
14
+ limiter = ActiveJob::Temporal.config.rate_limiter
15
+ raise ConfigurationError, "rate_limiter is required when rate limits are configured" unless limiter
16
+
17
+ normalize_wait_time(call_limiter(limiter, rate_limits))
18
+ end
19
+
20
+ private
21
+
22
+ def call_limiter(limiter, rate_limits)
23
+ return limiter.wait_time_for(rate_limits) if limiter.respond_to?(:wait_time_for)
24
+ return limiter.call(rate_limits) if limiter.respond_to?(:call)
25
+
26
+ raise ConfigurationError, "rate_limiter must respond to #wait_time_for or #call"
27
+ end
28
+
29
+ def normalize_wait_time(value)
30
+ wait_time = Float(value)
31
+ raise ArgumentError, "rate limiter wait time must be finite" unless wait_time.finite?
32
+ raise ArgumentError, "rate limiter wait time must not be negative" if wait_time.negative?
33
+
34
+ wait_time
35
+ rescue TypeError
36
+ raise ArgumentError, "rate limiter wait time must be numeric"
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,257 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job"
4
+ require "active_job/queue_adapters/abstract_adapter"
5
+ require_relative "workflow_id_builder"
6
+
7
+ module ActiveJob
8
+ module Temporal
9
+ # Helper methods for the TemporalAdapter.
10
+ #
11
+ # This module provides utility functions for building workflow IDs and resolving
12
+ # task queue names. Used internally by the adapter.
13
+ module Adapter
14
+ module_function
15
+
16
+ # Builds deterministic workflow ID used for Temporal workflows.
17
+ #
18
+ # Delegates ID construction to WorkflowIdBuilder while preserving the public
19
+ # helper used by integrations and tests. Creates a unique, reproducible
20
+ # workflow ID from the job class and job ID.
21
+ # This enables idempotent enqueuing: duplicate enqueue calls with the same job_id
22
+ # will be rejected by Temporal's FAIL conflict policy.
23
+ #
24
+ # @param job [ActiveJob::Base] ActiveJob instance being enqueued
25
+ # @return [String] Workflow ID in format "ajwf:<ClassName>:<job_id>"
26
+ #
27
+ # @note Idempotency Guarantee
28
+ # The workflow ID format ensures that jobs with the same job_id will never
29
+ # execute twice. This is critical for preventing duplicate processing in
30
+ # distributed systems.
31
+ #
32
+ # @example Basic usage
33
+ # job = MyJob.new
34
+ # job.job_id # => "abc-123"
35
+ # build_workflow_id(job) # => "ajwf:MyJob:abc-123"
36
+ #
37
+ # @example Duplicate enqueue
38
+ # MyJob.set(job_id: "unique-id").perform_later("arg") # First enqueue succeeds
39
+ # MyJob.set(job_id: "unique-id").perform_later("arg") # Second enqueue returns false
40
+ #
41
+ # @see TemporalAdapter#enqueue
42
+ def build_workflow_id(job)
43
+ WorkflowIdBuilder.new(configured_workflow_id_generator).build(job)
44
+ end
45
+
46
+ def configured_workflow_id_generator
47
+ ActiveJob::Temporal.config.workflow_id_generator if ActiveJob::Temporal.respond_to?(:config)
48
+ end
49
+ private_class_method :configured_workflow_id_generator
50
+
51
+ # Resolves the Temporal task queue name for a given job.
52
+ #
53
+ # Extracts the queue name from the job and applies the configured task_queue_prefix
54
+ # if present. Defaults to "default" if queue_name is blank.
55
+ #
56
+ # @param job [ActiveJob::Base] ActiveJob instance being enqueued
57
+ # @return [String] Task queue name, optionally prefixed
58
+ # @example Without prefix
59
+ # job.queue_name # => "mailers"
60
+ # resolve_task_queue(job) # => "mailers"
61
+ # @example With prefix
62
+ # ActiveJob::Temporal.config.task_queue_prefix = "myapp-"
63
+ # job.queue_name # => "mailers"
64
+ # resolve_task_queue(job) # => "myapp-mailers"
65
+ def resolve_task_queue(job, config: ActiveJob::Temporal.config)
66
+ queue_name = priority_task_queue(job, config) || job.queue_name.to_s.strip
67
+ queue_name = "default" if queue_name.empty?
68
+
69
+ prefix = config.task_queue_prefix
70
+ return queue_name if prefix.nil? || prefix.to_s.strip.empty?
71
+
72
+ "#{prefix}#{queue_name}"
73
+ end
74
+
75
+ def priority_task_queue(job, config)
76
+ priority_task_queues = config.respond_to?(:priority_task_queues) ? config.priority_task_queues : {}
77
+ return unless priority_task_queues.is_a?(Hash) && priority_task_queues.any?
78
+
79
+ return unless job.respond_to?(:priority)
80
+
81
+ job_priority = job.priority
82
+ return unless job_priority.is_a?(Integer)
83
+
84
+ priority_task_queues[job_priority]&.to_s&.strip
85
+ end
86
+ private_class_method :priority_task_queue
87
+ end
88
+ end
89
+ end
90
+
91
+ module ActiveJob
92
+ module QueueAdapters
93
+ # ActiveJob queue adapter for Temporal workflows.
94
+ #
95
+ # This adapter integrates ActiveJob with Temporal by starting workflows for each
96
+ # enqueued job. It translates ActiveJob's `perform_later` and `set(wait:).perform_later`
97
+ # into Temporal workflow starts with the AjWorkflow.
98
+ #
99
+ # @note Idempotent Enqueuing
100
+ # Jobs with the same job_id will not be enqueued twice. The adapter uses
101
+ # FAIL conflict policy, so duplicate enqueue attempts surface as
102
+ # DuplicateEnqueueError through ActiveJob's enqueue status.
103
+ #
104
+ # @note Transaction Safety
105
+ # Jobs using the Temporal adapter are opted into ActiveJob's
106
+ # `enqueue_after_transaction_commit` setting. This defers workflow starts
107
+ # until the current database transaction commits and prevents workflows from
108
+ # starting for rolled-back jobs.
109
+ #
110
+ # @example Basic usage
111
+ # class MyJob < ApplicationJob
112
+ # self.queue_adapter = :temporal
113
+ # def perform(arg)
114
+ # # job logic
115
+ # end
116
+ # end
117
+ # MyJob.perform_later("arg")
118
+ #
119
+ # @example Scheduled job
120
+ # MyJob.set(wait: 1.hour).perform_later("arg")
121
+ class TemporalAdapter < ActiveJob::QueueAdapters::AbstractAdapter
122
+ # @return [WorkflowEnqueuer] the enqueuer service
123
+ attr_reader :enqueuer
124
+
125
+ # Initialize the adapter with a WorkflowEnqueuer service instance.
126
+ def initialize
127
+ super
128
+
129
+ config = ActiveJob::Temporal.config
130
+ logger = config.logger
131
+
132
+ @enqueuer = ActiveJob::Temporal::WorkflowEnqueuer.new(
133
+ -> { ActiveJob::Temporal.client },
134
+ config,
135
+ logger
136
+ )
137
+ end
138
+
139
+ # Enqueues a job for immediate execution on Temporal by starting the AjWorkflow.
140
+ #
141
+ # Delegates to the WorkflowEnqueuer service to handle the mechanics of workflow
142
+ # creation and startup.
143
+ #
144
+ # @param job [ActiveJob::Base] the job instance provided by ActiveJob
145
+ # @return [Object] workflow run handle if provided by Temporal SDK
146
+ #
147
+ # @raise [ActiveJob::SerializationError] if payload serialization fails or exceeds max_payload_size_kb
148
+ # @raise [ActiveJob::EnqueueError] if the Temporal client cannot start the workflow
149
+ # @raise [ActiveJob::Temporal::ConfigurationError] if configuration is invalid
150
+ #
151
+ # @note FAIL Conflict Policy
152
+ # Duplicate job_id values raise DuplicateEnqueueError. ActiveJob catches
153
+ # this as an enqueue failure, so `perform_later` returns false and the
154
+ # yielded job exposes the error through `enqueue_error`.
155
+ #
156
+ # @example Basic usage
157
+ # adapter = TemporalAdapter.new
158
+ # job = MyJob.new("arg1", "arg2")
159
+ # adapter.enqueue(job)
160
+ #
161
+ # @example Handling serialization errors
162
+ # begin
163
+ # MyJob.perform_later(huge_object)
164
+ # rescue ActiveJob::SerializationError => e
165
+ # Rails.logger.error("Payload too large: #{e.message}")
166
+ # MyJob.perform_later(huge_object.id) # Pass ID instead
167
+ # end
168
+ #
169
+ # @example Handling configuration errors
170
+ # begin
171
+ # MyJob.perform_later("arg")
172
+ # rescue ActiveJob::Temporal::ConfigurationError => e
173
+ # # Configuration validation failed
174
+ # Rails.logger.fatal("Invalid Temporal configuration: #{e.message}")
175
+ # end
176
+ #
177
+ # @example Handling enqueue errors (cluster unreachable)
178
+ # begin
179
+ # MyJob.perform_later("arg")
180
+ # rescue ActiveJob::EnqueueError => e
181
+ # # Temporal cluster is down or network issue
182
+ # Rails.logger.error("Cannot enqueue job: #{e.message}")
183
+ # # Consider queuing to fallback system or retrying later
184
+ # end
185
+ #
186
+ # @see #enqueue_at
187
+ # @see https://docs.temporal.io/workflows#workflow-id-reuse-policy Temporal Workflow ID Policies
188
+ def enqueue(job)
189
+ @enqueuer.enqueue(job)
190
+ end
191
+
192
+ # Enqueues a job for execution at a specific time by starting the AjWorkflow immediately.
193
+ #
194
+ # The workflow starts immediately but sleeps (non-blockingly) until the scheduled time
195
+ # before executing the activity. This leverages Temporal's durable timers.
196
+ #
197
+ # @param job [ActiveJob::Base] the job instance provided by ActiveJob
198
+ # @param timestamp [Integer, Float] UNIX timestamp when the job should be executed
199
+ # @return [Object] workflow run handle if provided by Temporal SDK
200
+ #
201
+ # @raise [ActiveJob::SerializationError] if payload serialization fails or exceeds max_payload_size_kb
202
+ # @raise [ActiveJob::EnqueueError] if the Temporal client cannot start the workflow
203
+ # @raise [ActiveJob::Temporal::ConfigurationError] if configuration is invalid
204
+ #
205
+ # @note Non-Blocking Sleep
206
+ # The workflow uses Temporal's durable timer mechanism, so scheduled jobs
207
+ # do not consume worker resources while waiting.
208
+ #
209
+ # @example Basic usage
210
+ # adapter = TemporalAdapter.new
211
+ # job = MyJob.new("arg")
212
+ # adapter.enqueue_at(job, 1.hour.from_now.to_i)
213
+ #
214
+ # @example Scheduling with ActiveJob DSL
215
+ # MyJob.set(wait: 1.hour).perform_later("arg")
216
+ #
217
+ # @example Scheduling with wait_until
218
+ # MyJob.set(wait_until: Date.tomorrow.noon).perform_later("arg")
219
+ #
220
+ # @example Far-future scheduling (durable timer benefits)
221
+ # # Schedule a job 30 days in the future
222
+ # MyJob.set(wait: 30.days).perform_later("reminder", user_id: 123)
223
+ # # The workflow sleeps for 30 days without consuming resources
224
+ #
225
+ # @see #enqueue
226
+ # @see Workflows::AjWorkflow#sleep_until
227
+ def enqueue_at(job, timestamp)
228
+ scheduled_time = Time.at(timestamp)
229
+ @enqueuer.enqueue(job, scheduled_at: scheduled_time)
230
+ end
231
+
232
+ # Signals transaction-aware ActiveJob versions to defer enqueuing until after commit.
233
+ #
234
+ # Rails 8 uses the job class `enqueue_after_transaction_commit` setting instead. The
235
+ # TransactionSafety hook enables that setting when a job selects the Temporal adapter.
236
+ # This method remains for adapter-contract compatibility with older ActiveJob behavior.
237
+ #
238
+ # @return [Boolean] always returns true
239
+ # @example Transaction-safe enqueuing
240
+ # ActiveRecord::Base.transaction do
241
+ # user = User.create!(name: "Alice")
242
+ # MyJob.perform_later(user) # Deferred until commit
243
+ # raise ActiveRecord::Rollback # Job is NOT enqueued
244
+ # end
245
+ #
246
+ # @example Ensuring job runs after DB commit
247
+ # ActiveRecord::Base.transaction do
248
+ # order = Order.create!(amount: 100)
249
+ # PaymentJob.perform_later(order.id)
250
+ # # Job will not start until transaction commits successfully
251
+ # end
252
+ def enqueue_after_transaction_commit?
253
+ true
254
+ end
255
+ end
256
+ end
257
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module ActiveJob
6
+ module Temporal
7
+ module AuditLog
8
+ extend self
9
+
10
+ SENSITIVE_ATTRIBUTE_NAMES = %w[
11
+ arguments
12
+ args
13
+ cause
14
+ error
15
+ error_message
16
+ exception
17
+ message
18
+ payload
19
+ result
20
+ target
21
+ ].freeze
22
+
23
+ def record(event_name, attributes = {})
24
+ config = ActiveJob::Temporal.config
25
+ return unless config.audit_log
26
+
27
+ logger = config.audit_logger || config.logger
28
+ Logger.log_to(logger, :info, event_name, sanitized_attributes(attributes))
29
+ end
30
+
31
+ def job_attributes_from_payload(payload)
32
+ payload = payload.to_h
33
+ attributes = {
34
+ job_class: value_from(payload, :job_class),
35
+ job_id: value_from(payload, :job_id),
36
+ queue: value_from(payload, :queue_name),
37
+ executions: value_from(payload, :executions),
38
+ scheduled_at: value_from(payload, :scheduled_at)
39
+ }
40
+
41
+ attributes.compact
42
+ end
43
+
44
+ def activity_attributes_from_payload(payload)
45
+ job_attributes_from_payload(payload)
46
+ .merge(activity_context_attributes)
47
+ .merge(worker_id: ActiveJob::Temporal.config.identity)
48
+ .compact
49
+ end
50
+
51
+ def error_attributes(error)
52
+ {
53
+ error_class: error.class.name,
54
+ error_fingerprint: error_fingerprint(error)
55
+ }.compact
56
+ end
57
+
58
+ def cancelled_error?(error)
59
+ defined?(Temporalio::Error::CanceledError) && error.is_a?(Temporalio::Error::CanceledError)
60
+ end
61
+
62
+ def elapsed_milliseconds(started_at)
63
+ ((monotonic_time - started_at) * 1000).round(2)
64
+ end
65
+
66
+ def monotonic_time
67
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
68
+ end
69
+
70
+ private
71
+
72
+ def sanitized_attributes(attributes)
73
+ attributes.to_h.each_with_object({}) do |(key, value), result|
74
+ next if SENSITIVE_ATTRIBUTE_NAMES.include?(key.to_s)
75
+ next if value.nil?
76
+
77
+ result[key] = value
78
+ end
79
+ end
80
+
81
+ def value_from(hash, key)
82
+ hash[key] || hash[key.to_s]
83
+ end
84
+
85
+ def activity_context_attributes
86
+ return {} unless defined?(Temporalio::Activity::Context)
87
+ return {} unless Temporalio::Activity::Context.exist?
88
+
89
+ activity_info = Temporalio::Activity::Context.current.info
90
+ {
91
+ workflow_id: context_value(activity_info, :workflow_id),
92
+ run_id: context_value(activity_info, :workflow_run_id, :run_id),
93
+ attempt: context_value(activity_info, :attempt)
94
+ }.compact
95
+ rescue StandardError
96
+ {}
97
+ end
98
+
99
+ def context_value(object, *methods)
100
+ methods.each do |method_name|
101
+ return object.public_send(method_name) if object.respond_to?(method_name)
102
+ end
103
+
104
+ nil
105
+ end
106
+
107
+ def error_fingerprint(error)
108
+ source = [
109
+ error.class.name,
110
+ error.message,
111
+ *Array(error.backtrace).first(20)
112
+ ].compact.join("\n")
113
+
114
+ Digest::SHA256.hexdigest(source)
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "configuration"
4
+
5
+ module ActiveJob
6
+ module Temporal
7
+ class BatchEnqueueValidationError < Error
8
+ attr_reader :errors
9
+
10
+ def initialize(errors)
11
+ @errors = errors
12
+ super("Invalid batch enqueue jobs: #{format_errors(errors)}")
13
+ end
14
+
15
+ private
16
+
17
+ def format_errors(errors)
18
+ errors.map { |error| "item #{error[:index]} #{error[:error]}" }.join(", ")
19
+ end
20
+ end
21
+
22
+ class BatchEnqueueItemResult
23
+ attr_reader :index, :job, :status, :handle, :error
24
+
25
+ def initialize(index:, job:, status:, handle: nil, error: nil)
26
+ @index = index
27
+ @job = job
28
+ @status = status
29
+ @handle = handle
30
+ @error = error
31
+ end
32
+
33
+ def success?
34
+ status == :success
35
+ end
36
+
37
+ def duplicate?
38
+ status == :duplicate
39
+ end
40
+
41
+ def failure?
42
+ status == :failed
43
+ end
44
+
45
+ def to_h
46
+ {
47
+ index: index,
48
+ job_class: job.class.name,
49
+ job_id: job.job_id,
50
+ status: status,
51
+ handle: handle,
52
+ error: formatted_error
53
+ }.compact
54
+ end
55
+
56
+ private
57
+
58
+ def formatted_error
59
+ return unless error
60
+
61
+ "#{error.class}: #{error.message}"
62
+ end
63
+ end
64
+
65
+ class BatchEnqueueResult
66
+ attr_reader :results
67
+
68
+ def initialize(results)
69
+ @results = results
70
+ end
71
+
72
+ def success?
73
+ failures.empty?
74
+ end
75
+
76
+ def successes
77
+ results.select(&:success?)
78
+ end
79
+
80
+ def duplicates
81
+ results.select(&:duplicate?)
82
+ end
83
+
84
+ def failures
85
+ results.select(&:failure?)
86
+ end
87
+
88
+ def success_count
89
+ successes.length
90
+ end
91
+
92
+ def duplicate_count
93
+ duplicates.length
94
+ end
95
+
96
+ def failure_count
97
+ failures.length
98
+ end
99
+
100
+ def to_h
101
+ {
102
+ success_count: success_count,
103
+ duplicate_count: duplicate_count,
104
+ failure_count: failure_count,
105
+ results: results.map(&:to_h)
106
+ }
107
+ end
108
+ end
109
+ end
110
+ end