activejob-temporal 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +1 -0
  3. data/CHANGELOG.md +18 -0
  4. data/CLAUDE.md +274 -0
  5. data/CONTRIBUTING.md +69 -0
  6. data/README.md +34 -33
  7. data/activejob-temporal.gemspec +8 -12
  8. data/api/job_payload_schema.json +51 -6
  9. data/bin/temporal-worker +3 -8
  10. data/gemfiles/activejob_contract.gemfile +8 -0
  11. data/gemfiles/temporalio_contract.gemfile +7 -0
  12. data/lib/activejob/temporal/activities/aj_runner_activity.rb +75 -19
  13. data/lib/activejob/temporal/activities/dependency_status_activity.rb +36 -19
  14. data/lib/activejob/temporal/adapter.rb +4 -3
  15. data/lib/activejob/temporal/batch_enqueuer.rb +42 -33
  16. data/lib/activejob/temporal/bind_policy.rb +1 -1
  17. data/lib/activejob/temporal/cancel.rb +66 -29
  18. data/lib/activejob/temporal/certificate_watcher.rb +41 -6
  19. data/lib/activejob/temporal/client.rb +4 -3
  20. data/lib/activejob/temporal/conditional_enqueue.rb +2 -1
  21. data/lib/activejob/temporal/configurable.rb +56 -13
  22. data/lib/activejob/temporal/configuration.rb +139 -7
  23. data/lib/activejob/temporal/configured_job_compatibility.rb +91 -9
  24. data/lib/activejob/temporal/connection_worker_pool.rb +18 -1
  25. data/lib/activejob/temporal/dead_letter_queue.rb +64 -18
  26. data/lib/activejob/temporal/dependency_options.rb +126 -16
  27. data/lib/activejob/temporal/health_check_server.rb +14 -17
  28. data/lib/activejob/temporal/http_line_reader.rb +20 -2
  29. data/lib/activejob/temporal/http_request_failure_handling.rb +41 -0
  30. data/lib/activejob/temporal/inspect.rb +17 -7
  31. data/lib/activejob/temporal/job_id_validation.rb +41 -0
  32. data/lib/activejob/temporal/job_payload_dependencies.rb +23 -0
  33. data/lib/activejob/temporal/locales/en.yml +15 -3
  34. data/lib/activejob/temporal/metrics_server.rb +15 -18
  35. data/lib/activejob/temporal/middleware/chain.rb +7 -0
  36. data/lib/activejob/temporal/observability.rb +14 -2
  37. data/lib/activejob/temporal/payload.rb +64 -25
  38. data/lib/activejob/temporal/payload_encryption.rb +9 -1
  39. data/lib/activejob/temporal/payload_serializers.rb +3 -0
  40. data/lib/activejob/temporal/payload_storage.rb +4 -4
  41. data/lib/activejob/temporal/rails_environment_loader.rb +1 -8
  42. data/lib/activejob/temporal/reload_signal_queue.rb +19 -19
  43. data/lib/activejob/temporal/retry_handler_extractor.rb +18 -3
  44. data/lib/activejob/temporal/schedulable.rb +5 -7
  45. data/lib/activejob/temporal/schedule.rb +2 -2
  46. data/lib/activejob/temporal/signal_query.rb +35 -23
  47. data/lib/activejob/temporal/temporal_options.rb +26 -1
  48. data/lib/activejob/temporal/tls_file.rb +16 -16
  49. data/lib/activejob/temporal/transaction_safety.rb +102 -1
  50. data/lib/activejob/temporal/version.rb +1 -1
  51. data/lib/activejob/temporal/visibility_query.rb +16 -1
  52. data/lib/activejob/temporal/worker_pool.rb +13 -0
  53. data/lib/activejob/temporal/worker_runtime.rb +16 -0
  54. data/lib/activejob/temporal/workflow_enqueuer.rb +2 -1
  55. data/lib/activejob/temporal/workflow_types.rb +10 -0
  56. data/lib/activejob/temporal/workflows/aj_workflow.rb +17 -4
  57. data/lib/activejob/temporal/workflows/workflow_dependencies.rb +148 -15
  58. data/lib/activejob/temporal.rb +2 -13
  59. data/test/mutant_unit_test.rb +13 -0
  60. metadata +43 -48
@@ -71,6 +71,15 @@
71
71
  "workflow_id_prefix": { "type": "string", "minLength": 1 }
72
72
  }
73
73
  },
74
+ "payload_encryption_context": {
75
+ "type": "object",
76
+ "additionalProperties": false,
77
+ "required": ["namespace", "workflow_id"],
78
+ "properties": {
79
+ "namespace": { "type": "string", "minLength": 1 },
80
+ "workflow_id": { "type": "string", "minLength": 1 }
81
+ }
82
+ },
74
83
  "external_temporal_options": {
75
84
  "type": "object",
76
85
  "required": ["task_queue"],
@@ -199,19 +208,35 @@
199
208
  "properties": {
200
209
  "job_class": { "type": "string", "minLength": 1 },
201
210
  "job_id": { "type": "string", "minLength": 1 },
202
- "workflow_id": { "type": "string", "minLength": 1 }
211
+ "workflow_id": { "type": "string", "minLength": 1 },
212
+ "run_id": { "type": "string", "minLength": 1 }
203
213
  }
204
214
  },
205
215
  "dependencies": {
206
216
  "type": "array",
207
217
  "minItems": 1,
208
218
  "items": { "$ref": "#/definitions/dependency" }
219
+ },
220
+ "dependency_wait": {
221
+ "type": "object",
222
+ "additionalProperties": false,
223
+ "required": ["timeout", "initial_interval", "max_interval", "backoff"],
224
+ "properties": {
225
+ "timeout": { "type": "number", "exclusiveMinimum": 0 },
226
+ "initial_interval": { "type": "number", "exclusiveMinimum": 0 },
227
+ "max_interval": { "type": "number", "exclusiveMinimum": 0 },
228
+ "backoff": { "type": "number", "minimum": 1.0 }
229
+ }
209
230
  }
210
231
  },
211
232
  "oneOf": [
212
233
  {
213
234
  "additionalProperties": false,
214
- "required": ["job_class", "job_id", "queue_name", "arguments"],
235
+ "required": ["job_class", "job_id", "queue_name"],
236
+ "anyOf": [
237
+ { "required": ["active_job"] },
238
+ { "required": ["arguments"] }
239
+ ],
215
240
  "properties": {
216
241
  "job_class": { "type": "string" },
217
242
  "job_id": { "type": "string" },
@@ -235,7 +260,12 @@
235
260
  "chain": { "$ref": "#/definitions/chain" },
236
261
  "dependencies": { "$ref": "#/definitions/dependencies" },
237
262
  "dependency_failure_policy": { "type": "string", "enum": ["fail", "ignore"] },
238
- "activity_task_queue": { "type": "string", "minLength": 1 }
263
+ "dependency_wait": { "$ref": "#/definitions/dependency_wait" },
264
+ "activity_task_queue": { "type": "string", "minLength": 1 },
265
+ "schedule_id": { "type": "string", "minLength": 1 },
266
+ "schedule_workflow_id_prefix": { "type": "string", "minLength": 1 },
267
+ "schedule_execution_job_id": { "type": "string", "minLength": 1 },
268
+ "payload_encryption_context": { "$ref": "#/definitions/payload_encryption_context" }
239
269
  }
240
270
  },
241
271
  {
@@ -258,7 +288,12 @@
258
288
  "chain": { "$ref": "#/definitions/chain" },
259
289
  "dependencies": { "$ref": "#/definitions/dependencies" },
260
290
  "dependency_failure_policy": { "type": "string", "enum": ["fail", "ignore"] },
261
- "activity_task_queue": { "type": "string", "minLength": 1 }
291
+ "dependency_wait": { "$ref": "#/definitions/dependency_wait" },
292
+ "activity_task_queue": { "type": "string", "minLength": 1 },
293
+ "schedule_id": { "type": "string", "minLength": 1 },
294
+ "schedule_workflow_id_prefix": { "type": "string", "minLength": 1 },
295
+ "schedule_execution_job_id": { "type": "string", "minLength": 1 },
296
+ "payload_encryption_context": { "$ref": "#/definitions/payload_encryption_context" }
262
297
  }
263
298
  },
264
299
  {
@@ -285,7 +320,12 @@
285
320
  "chain": { "$ref": "#/definitions/chain" },
286
321
  "dependencies": { "$ref": "#/definitions/dependencies" },
287
322
  "dependency_failure_policy": { "type": "string", "enum": ["fail", "ignore"] },
288
- "activity_task_queue": { "type": "string", "minLength": 1 }
323
+ "dependency_wait": { "$ref": "#/definitions/dependency_wait" },
324
+ "activity_task_queue": { "type": "string", "minLength": 1 },
325
+ "schedule_id": { "type": "string", "minLength": 1 },
326
+ "schedule_workflow_id_prefix": { "type": "string", "minLength": 1 },
327
+ "schedule_execution_job_id": { "type": "string", "minLength": 1 },
328
+ "payload_encryption_context": { "$ref": "#/definitions/payload_encryption_context" }
289
329
  },
290
330
  "dependencies": {
291
331
  "payload_serializer": ["payload_serializer_version"],
@@ -311,7 +351,12 @@
311
351
  "chain": { "$ref": "#/definitions/chain" },
312
352
  "dependencies": { "$ref": "#/definitions/dependencies" },
313
353
  "dependency_failure_policy": { "type": "string", "enum": ["fail", "ignore"] },
314
- "activity_task_queue": { "type": "string", "minLength": 1 }
354
+ "dependency_wait": { "$ref": "#/definitions/dependency_wait" },
355
+ "activity_task_queue": { "type": "string", "minLength": 1 },
356
+ "schedule_id": { "type": "string", "minLength": 1 },
357
+ "schedule_workflow_id_prefix": { "type": "string", "minLength": 1 },
358
+ "schedule_execution_job_id": { "type": "string", "minLength": 1 },
359
+ "payload_encryption_context": { "$ref": "#/definitions/payload_encryption_context" }
315
360
  }
316
361
  }
317
362
  ]
data/bin/temporal-worker CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
- require_relative "../lib/activejob/temporal"
4
+ require_relative "../lib/activejob/temporal/worker_runtime"
5
5
  require "optparse"
6
6
 
7
7
  begin
@@ -263,13 +263,8 @@ certificate_watcher = nil
263
263
  if config.tls_cert_watch
264
264
  certificate_watcher = ActiveJob::Temporal::CertificateWatcher.new(
265
265
  paths: ActiveJob::Temporal::CertificateWatcher.paths_from_config(config),
266
- reload_callback: lambda {
267
- begin
268
- client_reloader.reload(source: "file_watch")
269
- rescue StandardError
270
- # Reload failures are already logged and should not stop the watcher.
271
- end
272
- }
266
+ # Failures are logged by the reloader and retried by the watcher, so let them propagate.
267
+ reload_callback: -> { client_reloader.reload(source: "file_watch") }
273
268
  ).start
274
269
  end
275
270
 
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gemspec path: ".."
6
+
7
+ gem "activejob", ENV.fetch("ACTIVEJOB_VERSION")
8
+ gem "activemodel", ENV.fetch("ACTIVEMODEL_VERSION", ENV.fetch("ACTIVEJOB_VERSION"))
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gemspec path: ".."
6
+
7
+ gem "temporalio", ENV.fetch("TEMPORALIO_VERSION")
@@ -76,12 +76,24 @@ module ActiveJob
76
76
  end
77
77
  end
78
78
 
79
+ class DiscardRequested < StandardError
80
+ attr_reader :job, :original_error
81
+
82
+ def initialize(job, original_error)
83
+ @job = job
84
+ @original_error = original_error
85
+ super(original_error.message)
86
+ set_backtrace(original_error.backtrace)
87
+ end
88
+ end
89
+
79
90
  # Executes the job inside the Temporal activity context.
80
91
  #
81
- # @param payload [Hash] Job payload with serialized arguments and metadata
92
+ # @param payload [Hash] Job payload with serialized ActiveJob data and metadata
82
93
  # @option payload [String] :job_class Fully-qualified job class name (required)
83
94
  # @option payload [String] :job_id Unique job identifier
84
- # @option payload [Array] :arguments Serialized job arguments (via ActiveJob::Arguments)
95
+ # @option payload [Hash] :active_job Full ActiveJob serialized payload
96
+ # @option payload [Array] :arguments Legacy serialized job arguments
85
97
  # @option payload [String] :queue_name Target queue name
86
98
  # @option payload [Integer] :executions Current execution count
87
99
  # @option payload [Hash] :exception_executions Exception execution counts
@@ -98,7 +110,9 @@ module ActiveJob
98
110
  # execute({
99
111
  # job_class: "MyJob",
100
112
  # job_id: "123",
101
- # arguments: [{ "_aj_serialized" => "ActiveJob::Serializers::ObjectSerializer", "value" => {...} }]
113
+ # active_job: {
114
+ # "arguments" => [{ "_aj_serialized" => "ActiveJob::Serializers::ObjectSerializer", "value" => {...} }]
115
+ # }
102
116
  # })
103
117
  #
104
118
  # @example Accessing idempotency key in job
@@ -166,8 +180,9 @@ module ActiveJob
166
180
  apply_activity_retry_state(job_data, job_class)
167
181
  job = deserialize_job(job_data)
168
182
  intercept_active_job_retry(job)
183
+ intercept_active_job_discard(job)
169
184
 
170
- set_idempotency_key
185
+ set_idempotency_key(payload)
171
186
  perform_job(job)
172
187
  end
173
188
 
@@ -180,7 +195,7 @@ module ActiveJob
180
195
  def apply_schedule_execution_identity(payload)
181
196
  return unless payload_value(payload, :schedule_id)
182
197
 
183
- execution_job_id = payload_value(payload, :schedule_execution_job_id) || activity_workflow_id
198
+ execution_job_id = payload_value(payload, :schedule_execution_job_id) || activity_workflow_execution_identity
184
199
  return unless execution_job_id
185
200
 
186
201
  payload[:job_id] = execution_job_id
@@ -218,6 +233,18 @@ module ActiveJob
218
233
  end
219
234
  end
220
235
 
236
+ def intercept_active_job_discard(job)
237
+ original_rescue_with_handler = job.method(:rescue_with_handler)
238
+ job.define_singleton_method(:rescue_with_handler) do |exception|
239
+ handled = original_rescue_with_handler.call(exception)
240
+ if handled && ActiveJob::Temporal::RetryMapper.discard_exception?(self.class, exception)
241
+ raise DiscardRequested.new(self, exception)
242
+ end
243
+
244
+ handled
245
+ end
246
+ end
247
+
221
248
  def apply_activity_retry_state(job_data, job_class)
222
249
  previous_attempts = activity_attempt - 1
223
250
  return if previous_attempts <= 0
@@ -256,7 +283,7 @@ module ActiveJob
256
283
  side_effects.after_failure("retry_observability") do
257
284
  record_retry_observability(retry_payload, observed_error)
258
285
  end
259
- handle_exception(job_class, error)
286
+ handle_exception(job_class, error, retry_payload)
260
287
  end
261
288
 
262
289
  def instrument_perform(payload, &)
@@ -313,13 +340,10 @@ module ActiveJob
313
340
  end
314
341
 
315
342
  # @api private
316
- def set_idempotency_key
317
- workflow_id = if defined?(Temporalio::Activity::Context) && Temporalio::Activity::Context.exist?
318
- Temporalio::Activity::Context.current.info.workflow_id
319
- else
320
- "unknown-workflow"
321
- end
322
- idempotency_key = "#{workflow_id}/runner"
343
+ def set_idempotency_key(payload = nil)
344
+ identity = payload && payload_value(payload, :schedule_execution_job_id)
345
+ identity ||= activity_workflow_id || "unknown-workflow"
346
+ idempotency_key = "#{identity}/runner"
323
347
  Thread.current[IDEMPOTENCY_KEY] = idempotency_key
324
348
  Fiber[IDEMPOTENCY_KEY] = idempotency_key
325
349
  end
@@ -329,10 +353,9 @@ module ActiveJob
329
353
  Fiber[IDEMPOTENCY_KEY] = nil
330
354
  end
331
355
 
356
+ # The AES-GCM authenticated data must come from the activity context, never from the
357
+ # payload: a payload-supplied context would let a tampered envelope pick its own AAD.
332
358
  def activity_encryption_context(payload = nil)
333
- payload_context = payload && (payload[:payload_encryption_context] || payload["payload_encryption_context"])
334
- return payload_context if payload_context
335
-
336
359
  return unless defined?(Temporalio::Activity::Context) && Temporalio::Activity::Context.exist?
337
360
 
338
361
  info = Temporalio::Activity::Context.current.info
@@ -341,7 +364,17 @@ module ActiveJob
341
364
  else
342
365
  ActiveJob::Temporal.config.namespace
343
366
  end
344
- { namespace: namespace, workflow_id: activity_workflow_id }
367
+ { namespace: namespace, workflow_id: encryption_context_workflow_id(payload) }
368
+ end
369
+
370
+ # Scheduled payloads are encrypted once against the schedule's workflow-ID prefix, while
371
+ # each occurrence runs under a longer workflow ID derived from that prefix.
372
+ def encryption_context_workflow_id(payload)
373
+ workflow_id = activity_workflow_id
374
+ prefix = payload && payload_value(payload, :schedule_workflow_id_prefix)
375
+ return workflow_id unless prefix && workflow_id.to_s.start_with?(prefix.to_s)
376
+
377
+ prefix
345
378
  end
346
379
 
347
380
  def activity_workflow_id
@@ -350,22 +383,45 @@ module ActiveJob
350
383
  Temporalio::Activity::Context.current.info.workflow_id
351
384
  end
352
385
 
386
+ def activity_workflow_execution_identity
387
+ return unless defined?(Temporalio::Activity::Context) && Temporalio::Activity::Context.exist?
388
+
389
+ info = Temporalio::Activity::Context.current.info
390
+ [info.workflow_id, activity_workflow_run_id(info)].compact.join(":")
391
+ end
392
+
393
+ def activity_workflow_run_id(info)
394
+ return info.workflow_run_id if info.respond_to?(:workflow_run_id)
395
+
396
+ info.run_id if info.respond_to?(:run_id)
397
+ end
398
+
353
399
  # Handles exceptions by checking discard_on declarations.
354
400
  # @api private
355
- def handle_exception(job_class, error)
401
+ def handle_exception(job_class, error, retry_payload)
356
402
  raise retryable_application_error(error) if error.is_a?(RetryRequested)
403
+ raise non_retryable_application_error(error.original_error) if error.is_a?(DiscardRequested)
357
404
 
358
405
  raise non_retryable_application_error(error) if job_class.nil? && deserialization_error?(error)
359
406
 
360
- raise non_retryable_application_error(error) if job_class && retry_attempts_exhausted?(job_class, error)
407
+ if job_class && retry_attempts_exhausted?(job_class, error)
408
+ raise error if dead_letter_payload?(retry_payload)
409
+
410
+ raise non_retryable_application_error(error)
411
+ end
361
412
 
362
413
  raise non_retryable_application_error(error) if job_class && RetryMapper.discard_exception?(job_class, error)
363
414
 
364
415
  raise error
365
416
  end
366
417
 
418
+ def dead_letter_payload?(payload)
419
+ payload_value(payload, :dead_letter)
420
+ end
421
+
367
422
  def observed_error_for(error)
368
423
  return error.original_error || error if error.is_a?(RetryRequested)
424
+ return error.original_error || error if error.is_a?(DiscardRequested)
369
425
 
370
426
  error
371
427
  end
@@ -9,17 +9,18 @@ require_relative "../workflow_id_builder"
9
9
  module ActiveJob
10
10
  module Temporal
11
11
  module Activities
12
+ DEPENDENCY_WORKFLOW_STATES = {
13
+ Temporalio::Client::WorkflowExecutionStatus::RUNNING => "running",
14
+ Temporalio::Client::WorkflowExecutionStatus::COMPLETED => "completed",
15
+ Temporalio::Client::WorkflowExecutionStatus::FAILED => "failed",
16
+ Temporalio::Client::WorkflowExecutionStatus::CANCELED => "canceled",
17
+ Temporalio::Client::WorkflowExecutionStatus::TERMINATED => "terminated",
18
+ Temporalio::Client::WorkflowExecutionStatus::CONTINUED_AS_NEW => "continued_as_new",
19
+ Temporalio::Client::WorkflowExecutionStatus::TIMED_OUT => "timed_out"
20
+ }.freeze
21
+
12
22
  class DependencyStatusActivity < Temporalio::Activity::Definition
13
23
  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
 
24
25
  def execute(dependencies)
25
26
  Array(dependencies).map { |dependency| status_for(normalize_dependency(dependency)) }
@@ -31,7 +32,9 @@ module ActiveJob
31
32
  workflow_reference = find_workflow_reference(dependency)
32
33
  return status_payload(dependency, "not_found") unless workflow_reference
33
34
 
34
- status_from_description(dependency, describe_workflow(workflow_reference))
35
+ description = describe_workflow(workflow_reference)
36
+ description = follow_continued_run(description, workflow_reference) if workflow_reference[:run_id]
37
+ status_from_description(dependency, description)
35
38
  rescue StandardError => e
36
39
  fallback_description = describe_search_attribute_workflow(dependency) if rpc_not_found?(e)
37
40
  return status_from_description(dependency, fallback_description) if fallback_description
@@ -43,21 +46,19 @@ module ActiveJob
43
46
  def status_from_description(dependency, description)
44
47
  status_payload(
45
48
  dependency,
46
- WORKFLOW_STATES.fetch(description.status, "unknown"),
49
+ DEPENDENCY_WORKFLOW_STATES.fetch(description.status, "unknown"),
47
50
  workflow_id: description.id,
48
51
  run_id: description.run_id
49
52
  )
50
53
  end
51
54
 
52
55
  def normalize_dependency(dependency)
53
- dependency.each_with_object({}) do |(key, value), normalized|
54
- normalized[key.to_s] = value
55
- end
56
+ dependency.to_h.transform_keys(&:to_s)
56
57
  end
57
58
 
58
59
  def find_workflow_reference(dependency)
59
60
  workflow_id = dependency["workflow_id"]
60
- return { workflow_id: workflow_id, run_id: nil } if workflow_id
61
+ return { workflow_id: workflow_id, run_id: dependency["run_id"] } if workflow_id
61
62
 
62
63
  search_workflow_reference(dependency) || default_workflow_reference(dependency)
63
64
  end
@@ -66,7 +67,7 @@ module ActiveJob
66
67
  job_id = dependency["job_id"]
67
68
  return unless job_id
68
69
 
69
- workflow = client.list_workflows(workflow_search_query(dependency)).first
70
+ workflow = search_ordered_workflow(dependency)
70
71
  return unless workflow
71
72
 
72
73
  {
@@ -79,6 +80,14 @@ module ActiveJob
79
80
  raise
80
81
  end
81
82
 
83
+ def search_ordered_workflow(dependency)
84
+ client.list_workflows(workflow_search_query(dependency, ordered: true)).first
85
+ rescue StandardError => e
86
+ raise unless rpc_invalid_argument?(e)
87
+
88
+ client.list_workflows(workflow_search_query(dependency, ordered: false)).first
89
+ end
90
+
82
91
  def default_workflow_reference(dependency)
83
92
  job_class = dependency["job_class"]
84
93
  job_id = dependency["job_id"]
@@ -92,11 +101,12 @@ module ActiveJob
92
101
  }
93
102
  end
94
103
 
95
- def workflow_search_query(dependency)
104
+ def workflow_search_query(dependency, ordered:)
96
105
  filters = ["ajJobId='#{safe_query_value(dependency.fetch('job_id'))}'"]
97
106
  job_class = dependency["job_class"]
98
107
  filters.unshift("ajClass='#{safe_query_value(job_class)}'") if job_class
99
- filters.join(" AND ")
108
+ query = filters.join(" AND ")
109
+ ordered ? "#{query} ORDER BY StartTime DESC" : query
100
110
  end
101
111
 
102
112
  def safe_query_value(value)
@@ -114,6 +124,13 @@ module ActiveJob
114
124
  ).describe
115
125
  end
116
126
 
127
+ def follow_continued_run(description, workflow_reference)
128
+ state = DEPENDENCY_WORKFLOW_STATES.fetch(description.status, "unknown")
129
+ return description unless state == "continued_as_new"
130
+
131
+ describe_workflow(workflow_reference.merge(run_id: nil))
132
+ end
133
+
117
134
  def describe_search_attribute_workflow(dependency)
118
135
  return unless dependency["workflow_id"] && dependency["job_id"]
119
136
 
@@ -134,7 +151,7 @@ module ActiveJob
134
151
  "job_id" => dependency["job_id"],
135
152
  "job_class" => dependency["job_class"],
136
153
  "workflow_id" => workflow_id || dependency["workflow_id"],
137
- "run_id" => run_id,
154
+ "run_id" => run_id || dependency["run_id"],
138
155
  "state" => state
139
156
  }.compact
140
157
  end
@@ -103,9 +103,10 @@ module ActiveJob
103
103
  #
104
104
  # @note Transaction Safety
105
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.
106
+ # `enqueue_after_transaction_commit` setting unless the job class configured
107
+ # it explicitly. This defers workflow starts until the current database
108
+ # transaction commits and prevents workflows from starting for rolled-back
109
+ # jobs.
109
110
  #
110
111
  # @example Basic usage
111
112
  # class MyJob < ApplicationJob
@@ -5,6 +5,9 @@ require_relative "batch_enqueue_result"
5
5
  module ActiveJob
6
6
  module Temporal
7
7
  class BatchEnqueuer
8
+ MAX_BATCH_SIZE = 10_000
9
+ QUEUE_STOP = Object.new.freeze
10
+
8
11
  def initialize(enqueue:, validate_job:, validate_scheduled_at:)
9
12
  @enqueue_job = enqueue
10
13
  @validate_job = validate_job
@@ -12,8 +15,8 @@ module ActiveJob
12
15
  end
13
16
 
14
17
  def enqueue(items, concurrency: 1)
15
- entries = validate_entries!(items)
16
18
  concurrency = validate_concurrency!(concurrency)
19
+ entries = validate_entries!(items)
17
20
  results = Array.new(entries.length)
18
21
 
19
22
  enqueue_entries(entries, results, concurrency)
@@ -28,9 +31,14 @@ module ActiveJob
28
31
  def validate_entries!(items)
29
32
  raise ArgumentError, "batch enqueue jobs must be an Enumerable" unless items.respond_to?(:each)
30
33
 
34
+ validate_batch_size_hint!(items)
35
+
31
36
  errors = []
32
- entries = items.each_with_index.map do |item, index|
33
- validate_entry(item, index, errors)
37
+ entries = []
38
+ items.each.with_index do |item, index|
39
+ raise_batch_size_error! if index >= MAX_BATCH_SIZE
40
+
41
+ entries << validate_entry(item, index, errors)
34
42
  end
35
43
 
36
44
  raise ArgumentError, "batch enqueue jobs cannot be empty" if entries.empty?
@@ -53,6 +61,20 @@ module ActiveJob
53
61
  nil
54
62
  end
55
63
 
64
+ def validate_batch_size_hint!(items)
65
+ return unless items.respond_to?(:size)
66
+
67
+ size = items.size
68
+ return if size.nil? || size <= MAX_BATCH_SIZE
69
+
70
+ raise_batch_size_error!
71
+ end
72
+
73
+ def raise_batch_size_error!
74
+ raise ArgumentError,
75
+ "batch enqueue accepts at most #{MAX_BATCH_SIZE} jobs; split larger inputs into smaller batches"
76
+ end
77
+
56
78
  def normalize_entry(item)
57
79
  return { job: item, scheduled_at: nil } if active_job_instance?(item)
58
80
 
@@ -82,19 +104,23 @@ module ActiveJob
82
104
  def enqueue_entries(entries, results, concurrency)
83
105
  return enqueue_sequentially(entries, results) if concurrency == 1
84
106
 
85
- entry_queue = Queue.new
86
- entries.each { |entry| entry_queue << entry }
87
107
  worker_count = [concurrency, entries.length].min
108
+ entry_queue = SizedQueue.new(worker_count)
88
109
 
89
- Array.new(worker_count) do
110
+ workers = Array.new(worker_count) do
90
111
  Thread.new do
91
112
  loop do
92
- enqueue_entry(entry_queue.pop(true), results)
93
- rescue ThreadError
94
- break
113
+ entry = entry_queue.pop
114
+ break if entry.equal?(QUEUE_STOP)
115
+
116
+ enqueue_entry(entry, results)
95
117
  end
96
118
  end
97
- end.each(&:value)
119
+ end
120
+
121
+ entries.each { |entry| entry_queue << entry }
122
+ worker_count.times { entry_queue << QUEUE_STOP }
123
+ workers.each(&:value)
98
124
  end
99
125
 
100
126
  def enqueue_sequentially(entries, results)
@@ -103,36 +129,19 @@ module ActiveJob
103
129
 
104
130
  def enqueue_entry(entry, results)
105
131
  handle = enqueue_job.call(entry[:job], scheduled_at: entry[:scheduled_at])
106
- results[entry[:index]] = success_result(entry, handle)
132
+ results[entry[:index]] = item_result(entry, status: :success, handle: handle)
107
133
  rescue DuplicateEnqueueError => e
108
- results[entry[:index]] = duplicate_result(entry, e)
134
+ results[entry[:index]] = item_result(entry, status: :duplicate, error: e)
109
135
  rescue StandardError => e
110
- results[entry[:index]] = failed_result(entry, e)
111
- end
112
-
113
- def success_result(entry, handle)
114
- BatchEnqueueItemResult.new(
115
- index: entry[:index],
116
- job: entry[:job],
117
- status: :success,
118
- handle: handle
119
- )
120
- end
121
-
122
- def duplicate_result(entry, error)
123
- BatchEnqueueItemResult.new(
124
- index: entry[:index],
125
- job: entry[:job],
126
- status: :duplicate,
127
- error: error
128
- )
136
+ results[entry[:index]] = item_result(entry, status: :failed, error: e)
129
137
  end
130
138
 
131
- def failed_result(entry, error)
139
+ def item_result(entry, status:, handle: nil, error: nil)
132
140
  BatchEnqueueItemResult.new(
133
141
  index: entry[:index],
134
142
  job: entry[:job],
135
- status: :failed,
143
+ status: status,
144
+ handle: handle,
136
145
  error: error
137
146
  )
138
147
  end
@@ -6,7 +6,7 @@ module ActiveJob
6
6
  module Temporal
7
7
  module BindPolicy
8
8
  LOOPBACK_HOSTNAMES = %w[localhost].freeze
9
- TRUE_VALUES = %w[1 true yes].freeze
9
+ TRUE_VALUES = %w[1 true yes on].freeze
10
10
 
11
11
  module_function
12
12