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,214 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module ActiveJob
|
|
7
|
+
module Temporal
|
|
8
|
+
# Structured logging for activejob-temporal gem.
|
|
9
|
+
#
|
|
10
|
+
# This module provides structured JSON logging with event names and typed attributes.
|
|
11
|
+
# It integrates with SemanticLogger if available, otherwise falls back to standard
|
|
12
|
+
# Ruby Logger with JSON formatting.
|
|
13
|
+
#
|
|
14
|
+
# All log entries include:
|
|
15
|
+
# - event: Event name (String or Symbol)
|
|
16
|
+
# - timestamp: ISO8601 UTC timestamp
|
|
17
|
+
# - Custom attributes (Hash)
|
|
18
|
+
#
|
|
19
|
+
# @note SemanticLogger Detection
|
|
20
|
+
# If the configured logger is a SemanticLogger instance, log entries are passed directly
|
|
21
|
+
# as hashes. Otherwise, the module JSON-stringifies the payload before passing it to the
|
|
22
|
+
# configured Ruby Logger instance.
|
|
23
|
+
#
|
|
24
|
+
# @example Basic logging
|
|
25
|
+
# Logger.info("job.enqueued", job_id: "123", queue: "default")
|
|
26
|
+
# # => { "event": "job.enqueued", "timestamp": "2025-10-29T12:00:00Z", "job_id": "123", "queue": "default" }
|
|
27
|
+
#
|
|
28
|
+
# @example Error logging
|
|
29
|
+
# Logger.error("job.failed", job_id: "123", error: "NetworkError")
|
|
30
|
+
#
|
|
31
|
+
# @example With SemanticLogger
|
|
32
|
+
# # If the configured logger is SemanticLogger, structured hash is passed directly
|
|
33
|
+
# Logger.info("workflow.started", workflow_id: "wf-123")
|
|
34
|
+
# # SemanticLogger formats as structured JSON
|
|
35
|
+
#
|
|
36
|
+
# @example Without SemanticLogger
|
|
37
|
+
# # Falls back to JSON.generate before calling logger.info
|
|
38
|
+
# Logger.info("workflow.started", workflow_id: "wf-123")
|
|
39
|
+
# # => '{"event":"workflow.started","timestamp":"2025-10-31T12:00:00Z","workflow_id":"wf-123"}'
|
|
40
|
+
module Logger
|
|
41
|
+
extend self
|
|
42
|
+
|
|
43
|
+
CONTROL_CHARACTER_PATTERN = /[[:cntrl:]]/
|
|
44
|
+
|
|
45
|
+
# Logs an event at INFO level.
|
|
46
|
+
#
|
|
47
|
+
# @param event_name [String, Symbol] Name of the event (e.g., "job.enqueued")
|
|
48
|
+
# @param attributes [Hash] Additional structured data to include in log entry
|
|
49
|
+
# @return [void]
|
|
50
|
+
# @raise [ArgumentError] if event_name is not a String or Symbol
|
|
51
|
+
# @raise [ArgumentError] if attributes is not a Hash
|
|
52
|
+
# @raise [NoMethodError] if logger is not configured
|
|
53
|
+
# @example
|
|
54
|
+
# Logger.log_event("workflow.started", workflow_id: "wf-123", job_class: "MyJob")
|
|
55
|
+
#
|
|
56
|
+
# @example With complex attributes
|
|
57
|
+
# Logger.log_event("job.completed", {
|
|
58
|
+
# job_id: "abc-123",
|
|
59
|
+
# duration_ms: 1500,
|
|
60
|
+
# result: { success: true, records_processed: 100 }
|
|
61
|
+
# })
|
|
62
|
+
def log_event(event_name, attributes = {})
|
|
63
|
+
log(:info, event_name, attributes)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Logs an event at INFO level.
|
|
67
|
+
#
|
|
68
|
+
# @param event_name [String, Symbol] Name of the event
|
|
69
|
+
# @param attributes [Hash] Additional structured data
|
|
70
|
+
# @return [void]
|
|
71
|
+
# @raise [ArgumentError] if event_name is not a String or Symbol
|
|
72
|
+
# @raise [ArgumentError] if attributes is not a Hash
|
|
73
|
+
# @raise [NoMethodError] if logger is not configured
|
|
74
|
+
# @example
|
|
75
|
+
# Logger.info("job.completed", job_id: "123", duration_ms: 1500)
|
|
76
|
+
def info(event_name, attributes = {})
|
|
77
|
+
log(:info, event_name, attributes)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Logs an event at WARN level.
|
|
81
|
+
#
|
|
82
|
+
# @param event_name [String, Symbol] Name of the event
|
|
83
|
+
# @param attributes [Hash] Additional structured data
|
|
84
|
+
# @return [void]
|
|
85
|
+
# @raise [ArgumentError] if event_name is not a String or Symbol
|
|
86
|
+
# @raise [ArgumentError] if attributes is not a Hash
|
|
87
|
+
# @raise [NoMethodError] if logger is not configured
|
|
88
|
+
# @example
|
|
89
|
+
# Logger.warn("job.retry", job_id: "123", attempt: 2, error: "Timeout")
|
|
90
|
+
def warn(event_name, attributes = {})
|
|
91
|
+
log(:warn, event_name, attributes)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Logs an event at ERROR level.
|
|
95
|
+
#
|
|
96
|
+
# @param event_name [String, Symbol] Name of the event
|
|
97
|
+
# @param attributes [Hash] Additional structured data
|
|
98
|
+
# @return [void]
|
|
99
|
+
# @raise [ArgumentError] if event_name is not a String or Symbol
|
|
100
|
+
# @raise [ArgumentError] if attributes is not a Hash
|
|
101
|
+
# @raise [NoMethodError] if logger is not configured
|
|
102
|
+
# @example
|
|
103
|
+
# Logger.error("job.failed", job_id: "123", error_class: "RuntimeError", message: "Boom")
|
|
104
|
+
def error(event_name, attributes = {})
|
|
105
|
+
log(:error, event_name, attributes)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def log_to(configured_logger, level, event_name, attributes = {})
|
|
109
|
+
log(level, event_name, attributes, configured_logger: configured_logger)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
# Internal logging method that handles all log levels.
|
|
115
|
+
# @api private
|
|
116
|
+
def log(level, event_name, attributes, configured_logger: ActiveJob::Temporal.config.logger)
|
|
117
|
+
validate_event!(event_name)
|
|
118
|
+
attributes = normalize_attributes(attributes)
|
|
119
|
+
|
|
120
|
+
payload = build_payload(sanitize_log_value(event_name), sanitize_log_value(attributes))
|
|
121
|
+
return unless configured_logger.respond_to?(level)
|
|
122
|
+
|
|
123
|
+
if semantic_logger?(configured_logger)
|
|
124
|
+
configured_logger.public_send(level, payload)
|
|
125
|
+
else
|
|
126
|
+
configured_logger.public_send(level, JSON.generate(payload))
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def sanitize_log_value(value)
|
|
131
|
+
case value
|
|
132
|
+
when String
|
|
133
|
+
escape_control_characters(value)
|
|
134
|
+
when Symbol
|
|
135
|
+
sanitize_symbol(value)
|
|
136
|
+
when Hash
|
|
137
|
+
value.each_with_object({}) do |(key, nested_value), result|
|
|
138
|
+
result[sanitize_log_key(key)] = sanitize_log_value(nested_value)
|
|
139
|
+
end
|
|
140
|
+
when Array
|
|
141
|
+
value.map { |element| sanitize_log_value(element) }
|
|
142
|
+
else
|
|
143
|
+
value
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def sanitize_log_key(key)
|
|
148
|
+
case key
|
|
149
|
+
when String
|
|
150
|
+
escape_control_characters(key)
|
|
151
|
+
when Symbol
|
|
152
|
+
sanitize_symbol(key)
|
|
153
|
+
else
|
|
154
|
+
key
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def sanitize_symbol(value)
|
|
159
|
+
sanitized = escape_control_characters(value.to_s)
|
|
160
|
+
sanitized == value.to_s ? value : sanitized
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def escape_control_characters(value)
|
|
164
|
+
return value unless value.match?(CONTROL_CHARACTER_PATTERN)
|
|
165
|
+
|
|
166
|
+
value.gsub(CONTROL_CHARACTER_PATTERN) { |character| format("\\u%04X", character.ord) }
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Builds structured log payload with event and timestamp.
|
|
170
|
+
# @api private
|
|
171
|
+
def build_payload(event_name, attributes)
|
|
172
|
+
{ event: event_name, timestamp: current_timestamp }.merge(attributes)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Returns current UTC timestamp in ISO8601 format.
|
|
176
|
+
# @api private
|
|
177
|
+
def current_timestamp
|
|
178
|
+
Time.now.utc.iso8601
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Normalizes attributes to a hash.
|
|
182
|
+
# @api private
|
|
183
|
+
def normalize_attributes(attributes)
|
|
184
|
+
case attributes
|
|
185
|
+
when nil then {}
|
|
186
|
+
when Hash then attributes.dup
|
|
187
|
+
else
|
|
188
|
+
raise ArgumentError, "attributes must be a Hash"
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Validates event_name is a String or Symbol.
|
|
193
|
+
# @api private
|
|
194
|
+
def validate_event!(event_name)
|
|
195
|
+
return if event_name.is_a?(String) || event_name.is_a?(Symbol)
|
|
196
|
+
|
|
197
|
+
raise ArgumentError, "event_name must be a String or Symbol"
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Checks if the configured logger handles structured payloads itself.
|
|
201
|
+
# @api private
|
|
202
|
+
def semantic_logger?(configured_logger)
|
|
203
|
+
return false unless defined?(SemanticLogger)
|
|
204
|
+
return true if semantic_logger_instance?(configured_logger)
|
|
205
|
+
|
|
206
|
+
configured_logger.class.name.to_s.start_with?("SemanticLogger::")
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def semantic_logger_instance?(configured_logger)
|
|
210
|
+
defined?(SemanticLogger::Logger) && configured_logger.is_a?(SemanticLogger::Logger)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "io/wait"
|
|
4
|
+
require "socket"
|
|
5
|
+
|
|
6
|
+
require_relative "connection_worker_pool"
|
|
7
|
+
require_relative "bind_policy"
|
|
8
|
+
require_relative "http_line_reader"
|
|
9
|
+
|
|
10
|
+
module ActiveJob
|
|
11
|
+
module Temporal
|
|
12
|
+
class MetricsServer
|
|
13
|
+
include HttpLineReader
|
|
14
|
+
|
|
15
|
+
DEFAULT_BIND_ADDRESS = "127.0.0.1"
|
|
16
|
+
READ_TIMEOUT_SECONDS = 1
|
|
17
|
+
CONTENT_TYPE = "text/plain; version=0.0.4"
|
|
18
|
+
CONNECTION_WORKERS = 4
|
|
19
|
+
CONNECTION_QUEUE_SIZE = 16
|
|
20
|
+
|
|
21
|
+
attr_reader :port, :bind_address
|
|
22
|
+
|
|
23
|
+
def initialize(port:, provider:, bind_address: DEFAULT_BIND_ADDRESS, allow_public_bind: false)
|
|
24
|
+
@requested_port = Integer(port)
|
|
25
|
+
@bind_address = bind_address
|
|
26
|
+
@allow_public_bind = allow_public_bind
|
|
27
|
+
@provider = provider
|
|
28
|
+
@running = false
|
|
29
|
+
@mutex = Mutex.new
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def start
|
|
33
|
+
@mutex.synchronize do
|
|
34
|
+
return self if @running
|
|
35
|
+
|
|
36
|
+
BindPolicy.validate!(
|
|
37
|
+
endpoint: "metrics",
|
|
38
|
+
bind_address: bind_address,
|
|
39
|
+
allow_public_bind: @allow_public_bind
|
|
40
|
+
)
|
|
41
|
+
@server = TCPServer.new(bind_address, @requested_port)
|
|
42
|
+
@port = @server.addr[1]
|
|
43
|
+
@connection_pool = ConnectionWorkerPool.new(
|
|
44
|
+
size: CONNECTION_WORKERS,
|
|
45
|
+
queue_size: CONNECTION_QUEUE_SIZE,
|
|
46
|
+
name: "activejob-temporal-metrics"
|
|
47
|
+
) { |client| serve_client(client) }.start
|
|
48
|
+
@running = true
|
|
49
|
+
@thread = Thread.new { run }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
self
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def stop
|
|
56
|
+
server = nil
|
|
57
|
+
thread = nil
|
|
58
|
+
connection_pool = nil
|
|
59
|
+
|
|
60
|
+
@mutex.synchronize do
|
|
61
|
+
server = @server
|
|
62
|
+
thread = @thread
|
|
63
|
+
connection_pool = @connection_pool
|
|
64
|
+
@server = nil
|
|
65
|
+
@thread = nil
|
|
66
|
+
@connection_pool = nil
|
|
67
|
+
@running = false
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
server&.close
|
|
71
|
+
connection_pool&.stop(timeout: 2)
|
|
72
|
+
thread&.join(2)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def running?
|
|
76
|
+
@mutex.synchronize { @running }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def run
|
|
82
|
+
loop do
|
|
83
|
+
server, connection_pool = @mutex.synchronize { [@server, @connection_pool] }
|
|
84
|
+
break unless server && connection_pool
|
|
85
|
+
|
|
86
|
+
connection_pool.enqueue(server.accept)
|
|
87
|
+
rescue IOError, Errno::EBADF
|
|
88
|
+
break
|
|
89
|
+
end
|
|
90
|
+
ensure
|
|
91
|
+
@mutex.synchronize { @running = false if @server }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def serve_client(client)
|
|
95
|
+
handle_client(client)
|
|
96
|
+
rescue IOError, SystemCallError
|
|
97
|
+
nil
|
|
98
|
+
ensure
|
|
99
|
+
client&.close
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def handle_client(client)
|
|
103
|
+
request_line = read_line(client)
|
|
104
|
+
return unless request_line
|
|
105
|
+
|
|
106
|
+
method, path = request_line.split.first(2)
|
|
107
|
+
drain_headers(client)
|
|
108
|
+
|
|
109
|
+
unless method && path
|
|
110
|
+
write_text(client, 400, "bad_request\n")
|
|
111
|
+
return
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
case [method, path]
|
|
115
|
+
in ["GET" | "HEAD", "/metrics"]
|
|
116
|
+
write_text(client, 200, @provider.render, body: method == "GET")
|
|
117
|
+
in [_, "/metrics"]
|
|
118
|
+
write_text(client, 405, "method_not_allowed\n")
|
|
119
|
+
else
|
|
120
|
+
write_text(client, 404, "not_found\n")
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def drain_headers(client)
|
|
125
|
+
loop do
|
|
126
|
+
line = read_line(client)
|
|
127
|
+
break if line.nil? || line == "\r\n" || line == "\n"
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def write_text(client, status, payload, body: true)
|
|
132
|
+
response = "HTTP/1.1 #{status} #{reason_phrase(status)}\r\n"
|
|
133
|
+
response << "Content-Type: #{CONTENT_TYPE}\r\n"
|
|
134
|
+
response << "Content-Length: #{body ? payload.bytesize : 0}\r\n"
|
|
135
|
+
response << "Connection: close\r\n\r\n"
|
|
136
|
+
response << payload if body
|
|
137
|
+
client.write(response)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def reason_phrase(status)
|
|
141
|
+
{
|
|
142
|
+
200 => "OK",
|
|
143
|
+
400 => "Bad Request",
|
|
144
|
+
404 => "Not Found",
|
|
145
|
+
405 => "Method Not Allowed"
|
|
146
|
+
}.fetch(status)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveJob
|
|
4
|
+
module Temporal
|
|
5
|
+
module Middleware
|
|
6
|
+
# Ordered middleware pipeline for ActiveJob execution inside Temporal activities.
|
|
7
|
+
class Chain
|
|
8
|
+
include Enumerable
|
|
9
|
+
|
|
10
|
+
TERMINAL_CALL_CHAIN = ->(_job, terminal) { terminal.call }.freeze
|
|
11
|
+
|
|
12
|
+
def initialize(entries = [])
|
|
13
|
+
@entries = []
|
|
14
|
+
@entry_indexes_by_key = {}
|
|
15
|
+
@compiled_call_chain = TERMINAL_CALL_CHAIN
|
|
16
|
+
entries.each { |entry| add(entry) }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def add(middleware, *args, **kwargs, &block)
|
|
20
|
+
key = entry_key(middleware, args, kwargs, block)
|
|
21
|
+
callable = build_callable(middleware, args, kwargs, block)
|
|
22
|
+
upsert_entry(key, callable)
|
|
23
|
+
@compiled_call_chain = compile_call_chain
|
|
24
|
+
|
|
25
|
+
callable
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def call(job, &terminal)
|
|
29
|
+
raise ArgumentError, "middleware chain requires a block" unless terminal
|
|
30
|
+
|
|
31
|
+
@compiled_call_chain.call(job, terminal)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def each(&block)
|
|
35
|
+
return enum_for(:each) unless block
|
|
36
|
+
|
|
37
|
+
@entries.each(&block)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def upsert_entry(key, callable)
|
|
43
|
+
if @entry_indexes_by_key.key?(key)
|
|
44
|
+
@entries[@entry_indexes_by_key.fetch(key)] = callable
|
|
45
|
+
else
|
|
46
|
+
@entry_indexes_by_key[key] = @entries.length
|
|
47
|
+
@entries << callable
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def entry_key(middleware, args, kwargs, block)
|
|
52
|
+
[
|
|
53
|
+
middleware_key(middleware),
|
|
54
|
+
args.map { |argument| argument_key(argument) },
|
|
55
|
+
kwargs.sort_by { |key, _value| key.to_s }.map { |key, value| [key, argument_key(value)] },
|
|
56
|
+
block_key(block)
|
|
57
|
+
]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def middleware_key(middleware)
|
|
61
|
+
return [:class, middleware.name] if middleware.is_a?(Class) && middleware.name
|
|
62
|
+
return [:callable_source, middleware.source_location] if middleware.respond_to?(:source_location)
|
|
63
|
+
|
|
64
|
+
[:object, middleware.object_id]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def argument_key(argument)
|
|
68
|
+
case argument
|
|
69
|
+
when NilClass, TrueClass, FalseClass, Numeric, Symbol
|
|
70
|
+
argument
|
|
71
|
+
when String
|
|
72
|
+
argument.dup.freeze
|
|
73
|
+
else
|
|
74
|
+
[:object, argument.object_id]
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def block_key(block)
|
|
79
|
+
block&.source_location || block&.object_id
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def compile_call_chain
|
|
83
|
+
@entries.reverse_each.reduce(TERMINAL_CALL_CHAIN) do |next_middleware, middleware|
|
|
84
|
+
lambda do |job, terminal|
|
|
85
|
+
middleware.call(job) { next_middleware.call(job, terminal) }
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def build_callable(middleware, args, kwargs, block)
|
|
91
|
+
callable = if middleware.is_a?(Class)
|
|
92
|
+
middleware.new(*args, **kwargs, &block)
|
|
93
|
+
elsif args.empty? && kwargs.empty? && block.nil?
|
|
94
|
+
middleware
|
|
95
|
+
else
|
|
96
|
+
raise ArgumentError, "middleware arguments require a middleware class"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
return callable if callable.respond_to?(:call)
|
|
100
|
+
|
|
101
|
+
raise ArgumentError, "middleware must respond to #call"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../observability"
|
|
4
|
+
|
|
5
|
+
module ActiveJob
|
|
6
|
+
module Temporal
|
|
7
|
+
module Observability
|
|
8
|
+
# rubocop:disable Metrics/ClassLength
|
|
9
|
+
class Datadog < Adapter
|
|
10
|
+
attr_accessor :service, :statsd_host, :statsd_port
|
|
11
|
+
attr_writer :statsd
|
|
12
|
+
|
|
13
|
+
def initialize(service: "activejob-temporal", statsd: nil, statsd_host: "127.0.0.1", statsd_port: 8125)
|
|
14
|
+
super(:datadog)
|
|
15
|
+
@service = service
|
|
16
|
+
@statsd = statsd
|
|
17
|
+
@statsd_host = statsd_host
|
|
18
|
+
@statsd_port = statsd_port
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def trace_context_for_enqueue(_payload)
|
|
22
|
+
trace = ::Datadog::Tracing.active_trace if defined?(::Datadog::Tracing)
|
|
23
|
+
return {} unless trace.respond_to?(:to_digest)
|
|
24
|
+
|
|
25
|
+
carrier = {}
|
|
26
|
+
::Datadog::Tracing::Contrib::HTTP.inject(trace.to_digest, carrier)
|
|
27
|
+
carrier
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def record(event_name, payload)
|
|
31
|
+
case event_name
|
|
32
|
+
when :enqueue then record_enqueue(payload)
|
|
33
|
+
when :payload_serialize then record_payload_size(payload)
|
|
34
|
+
when :retry then record_retry(payload)
|
|
35
|
+
when :worker_start then record_worker_started(payload)
|
|
36
|
+
when :worker_stop then record_worker_stopped(payload)
|
|
37
|
+
when :active_tasks then record_active_tasks(payload)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def instrument(event_name, payload)
|
|
42
|
+
return yield unless event_name == :perform
|
|
43
|
+
|
|
44
|
+
started_at = monotonic_time
|
|
45
|
+
trace(event_name, payload) do
|
|
46
|
+
result = yield
|
|
47
|
+
statsd.increment("activejob_temporal.jobs.completed", tags: tags(payload))
|
|
48
|
+
result
|
|
49
|
+
rescue StandardError => e
|
|
50
|
+
statsd.increment("activejob_temporal.jobs.failed", tags: tags(payload, error: e.class.name))
|
|
51
|
+
raise
|
|
52
|
+
ensure
|
|
53
|
+
statsd.histogram(
|
|
54
|
+
"activejob_temporal.job_duration.seconds",
|
|
55
|
+
monotonic_time - started_at,
|
|
56
|
+
tags: tags(payload)
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def validate_dependencies!
|
|
62
|
+
require_dependency("datadog", "datadog", "Datadog")
|
|
63
|
+
require_dependency("datadog", "datadog/statsd", "Datadog")
|
|
64
|
+
require_dependency("datadog", "datadog/tracing/contrib/http", "Datadog")
|
|
65
|
+
self
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def record_enqueue(payload)
|
|
71
|
+
trace(:enqueue, payload) do
|
|
72
|
+
statsd.increment("activejob_temporal.jobs.enqueued", tags: tags(payload))
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def record_payload_size(payload)
|
|
77
|
+
statsd.histogram("activejob_temporal.payload_size.bytes", payload[:bytes], tags: tags(payload))
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def record_retry(payload)
|
|
81
|
+
statsd.increment("activejob_temporal.retries", tags: tags(payload, error: payload[:error]))
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def record_worker_started(payload)
|
|
85
|
+
statsd.gauge("activejob_temporal.active_workers", 1, tags: worker_tags(payload))
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def record_worker_stopped(payload)
|
|
89
|
+
statsd.gauge("activejob_temporal.active_workers", 0, tags: worker_tags(payload))
|
|
90
|
+
statsd.gauge("activejob_temporal.active_tasks", 0, tags: worker_tags(payload))
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def record_active_tasks(payload)
|
|
94
|
+
statsd.gauge("activejob_temporal.active_tasks", payload[:count].to_i, tags: worker_tags(payload))
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def trace(event_name, payload)
|
|
98
|
+
digest = extracted_digest(payload)
|
|
99
|
+
options = {
|
|
100
|
+
service: service,
|
|
101
|
+
resource: payload[:job_class]
|
|
102
|
+
}.compact
|
|
103
|
+
options[:continue_from] = digest if digest
|
|
104
|
+
|
|
105
|
+
::Datadog::Tracing.trace("activejob_temporal.#{event_name}", **options) do |span|
|
|
106
|
+
set_span_tags(span, payload)
|
|
107
|
+
yield
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def extracted_digest(payload)
|
|
112
|
+
carrier = Observability.trace_context_from_payload(payload).fetch("datadog", nil)
|
|
113
|
+
return if carrier.nil? || carrier.empty?
|
|
114
|
+
|
|
115
|
+
::Datadog::Tracing::Contrib::HTTP.extract(carrier)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def set_span_tags(span, payload)
|
|
119
|
+
span.set_tag("activejob_temporal.job_class", payload[:job_class])
|
|
120
|
+
span.set_tag("activejob_temporal.job_id", payload[:job_id])
|
|
121
|
+
span.set_tag("activejob_temporal.queue", payload[:queue])
|
|
122
|
+
span.set_tag("activejob_temporal.workflow_id", payload[:workflow_id])
|
|
123
|
+
span.set_tag("activejob_temporal.run_id", payload[:run_id])
|
|
124
|
+
span.set_tag("activejob_temporal.namespace", payload[:namespace])
|
|
125
|
+
span.set_tag("activejob_temporal.task_queue", payload[:task_queue])
|
|
126
|
+
span.set_tag("activejob_temporal.attempt", payload[:attempt])
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def statsd
|
|
130
|
+
@statsd ||= ::Datadog::Statsd.new(statsd_host, statsd_port)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def tags(payload, error: nil)
|
|
134
|
+
[
|
|
135
|
+
tag("job_class", payload[:job_class]),
|
|
136
|
+
tag("queue", payload[:queue]),
|
|
137
|
+
tag("namespace", payload[:namespace]),
|
|
138
|
+
tag("task_queue", payload[:task_queue]),
|
|
139
|
+
tag("error", error)
|
|
140
|
+
].compact
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def worker_tags(payload)
|
|
144
|
+
[
|
|
145
|
+
tag("namespace", payload[:namespace]),
|
|
146
|
+
tag("task_queue", payload[:task_queue]),
|
|
147
|
+
tag("worker_id", payload[:worker_id])
|
|
148
|
+
].compact
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def tag(name, value)
|
|
152
|
+
"#{name}:#{value}" unless value.nil?
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def monotonic_time
|
|
156
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
# rubocop:enable Metrics/ClassLength
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
ActiveJob::Temporal::Observability.register_adapter(
|
|
165
|
+
:datadog,
|
|
166
|
+
ActiveJob::Temporal::Observability::Datadog
|
|
167
|
+
)
|