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,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
|