activejob-temporal 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +130 -0
- data/LICENSE +21 -0
- data/README.md +198 -0
- data/activejob-temporal.gemspec +58 -0
- data/api/job_payload_schema.json +318 -0
- data/bin/temporal-worker +295 -0
- data/lib/activejob/temporal/active_job_handler_source.rb +84 -0
- data/lib/activejob/temporal/activities/aj_runner_activity.rb +454 -0
- data/lib/activejob/temporal/activities/best_effort_side_effects.rb +49 -0
- data/lib/activejob/temporal/activities/dependency_status_activity.rb +160 -0
- data/lib/activejob/temporal/activities/rate_limit_activity.rb +41 -0
- data/lib/activejob/temporal/adapter.rb +257 -0
- data/lib/activejob/temporal/audit_log.rb +118 -0
- data/lib/activejob/temporal/batch_enqueue_result.rb +110 -0
- data/lib/activejob/temporal/batch_enqueuer.rb +141 -0
- data/lib/activejob/temporal/bind_policy.rb +44 -0
- data/lib/activejob/temporal/cancel/batch_canceller.rb +154 -0
- data/lib/activejob/temporal/cancel/batch_summary.rb +45 -0
- data/lib/activejob/temporal/cancel.rb +236 -0
- data/lib/activejob/temporal/certificate_watcher.rb +76 -0
- data/lib/activejob/temporal/chain_options.rb +83 -0
- data/lib/activejob/temporal/child_workflow_options.rb +102 -0
- data/lib/activejob/temporal/client.rb +215 -0
- data/lib/activejob/temporal/conditional_enqueue.rb +56 -0
- data/lib/activejob/temporal/configurable.rb +55 -0
- data/lib/activejob/temporal/configuration.rb +981 -0
- data/lib/activejob/temporal/configured_job_compatibility.rb +44 -0
- data/lib/activejob/temporal/connection_worker_pool.rb +88 -0
- data/lib/activejob/temporal/dead_letter_payload_validation.rb +34 -0
- data/lib/activejob/temporal/dead_letter_queue.rb +163 -0
- data/lib/activejob/temporal/dependency_options.rb +134 -0
- data/lib/activejob/temporal/external_operation.rb +193 -0
- data/lib/activejob/temporal/health_check_server.rb +159 -0
- data/lib/activejob/temporal/http_line_reader.rb +36 -0
- data/lib/activejob/temporal/inspect.rb +184 -0
- data/lib/activejob/temporal/job_descriptor.rb +37 -0
- data/lib/activejob/temporal/job_payload_builder.rb +209 -0
- data/lib/activejob/temporal/job_payload_chain_builder.rb +106 -0
- data/lib/activejob/temporal/job_payload_child_workflows.rb +127 -0
- data/lib/activejob/temporal/job_payload_dependencies.rb +40 -0
- data/lib/activejob/temporal/job_payload_rate_limits.rb +53 -0
- data/lib/activejob/temporal/job_payload_workflow_interactions.rb +31 -0
- data/lib/activejob/temporal/job_tags.rb +40 -0
- data/lib/activejob/temporal/locales/en.yml +126 -0
- data/lib/activejob/temporal/logger.rb +214 -0
- data/lib/activejob/temporal/metrics_server.rb +150 -0
- data/lib/activejob/temporal/middleware/chain.rb +106 -0
- data/lib/activejob/temporal/middleware.rb +11 -0
- data/lib/activejob/temporal/observability/datadog.rb +167 -0
- data/lib/activejob/temporal/observability/opentelemetry.rb +107 -0
- data/lib/activejob/temporal/observability/prometheus.rb +271 -0
- data/lib/activejob/temporal/observability.rb +260 -0
- data/lib/activejob/temporal/payload.rb +415 -0
- data/lib/activejob/temporal/payload_encryption.rb +215 -0
- data/lib/activejob/temporal/payload_serializers/json.rb +23 -0
- data/lib/activejob/temporal/payload_serializers/marshal.rb +53 -0
- data/lib/activejob/temporal/payload_serializers/message_pack.rb +59 -0
- data/lib/activejob/temporal/payload_serializers.rb +37 -0
- data/lib/activejob/temporal/payload_storage.rb +103 -0
- data/lib/activejob/temporal/rails_environment_loader.rb +143 -0
- data/lib/activejob/temporal/rate_limit_options.rb +94 -0
- data/lib/activejob/temporal/rate_limiters/memory.rb +198 -0
- data/lib/activejob/temporal/reload_signal_queue.rb +40 -0
- data/lib/activejob/temporal/retry_handler_extractor.rb +361 -0
- data/lib/activejob/temporal/retry_mapper.rb +264 -0
- data/lib/activejob/temporal/schedulable.rb +60 -0
- data/lib/activejob/temporal/schedule.rb +181 -0
- data/lib/activejob/temporal/schedule_options.rb +105 -0
- data/lib/activejob/temporal/search_attributes.rb +173 -0
- data/lib/activejob/temporal/signal_query.rb +161 -0
- data/lib/activejob/temporal/signal_query_options.rb +106 -0
- data/lib/activejob/temporal/temporal_options.rb +114 -0
- data/lib/activejob/temporal/tls_file.rb +45 -0
- data/lib/activejob/temporal/transaction_safety.rb +39 -0
- data/lib/activejob/temporal/version.rb +7 -0
- data/lib/activejob/temporal/visibility_query.rb +13 -0
- data/lib/activejob/temporal/worker_client_reloader.rb +34 -0
- data/lib/activejob/temporal/worker_health.rb +117 -0
- data/lib/activejob/temporal/worker_pool.rb +408 -0
- data/lib/activejob/temporal/workflow_enqueuer.rb +271 -0
- data/lib/activejob/temporal/workflow_enqueuer_batch.rb +17 -0
- data/lib/activejob/temporal/workflow_id_builder.rb +155 -0
- data/lib/activejob/temporal/workflow_identity.rb +62 -0
- data/lib/activejob/temporal/workflows/aj_workflow.rb +282 -0
- data/lib/activejob/temporal/workflows/dead_letter_support.rb +134 -0
- data/lib/activejob/temporal/workflows/dead_letter_workflow.rb +114 -0
- data/lib/activejob/temporal/workflows/workflow_chaining.rb +194 -0
- data/lib/activejob/temporal/workflows/workflow_child_workflows.rb +140 -0
- data/lib/activejob/temporal/workflows/workflow_continue_as_new.rb +44 -0
- data/lib/activejob/temporal/workflows/workflow_dependencies.rb +115 -0
- data/lib/activejob/temporal/workflows/workflow_execution_steps.rb +22 -0
- data/lib/activejob/temporal/workflows/workflow_interactions.rb +215 -0
- data/lib/activejob/temporal/workflows/workflow_local_activities.rb +29 -0
- data/lib/activejob/temporal/workflows/workflow_nexus.rb +15 -0
- data/lib/activejob/temporal/workflows/workflow_versioning.rb +21 -0
- data/lib/activejob/temporal.rb +297 -0
- data/lib/activejob-temporal.rb +3 -0
- metadata +423 -0
|
@@ -0,0 +1,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
|