activejob-temporal 0.1.0 → 0.3.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 +4 -4
- data/AGENTS.md +1 -0
- data/CHANGELOG.md +29 -0
- data/CLAUDE.md +274 -0
- data/CONTRIBUTING.md +69 -0
- data/README.md +34 -33
- data/activejob-temporal.gemspec +9 -13
- data/api/job_payload_schema.json +51 -6
- data/bin/temporal-worker +25 -17
- data/gemfiles/activejob_contract.gemfile +8 -0
- data/gemfiles/temporalio_contract.gemfile +7 -0
- data/lib/activejob/temporal/activities/aj_runner_activity.rb +75 -19
- data/lib/activejob/temporal/activities/dependency_status_activity.rb +36 -19
- data/lib/activejob/temporal/adapter.rb +4 -3
- data/lib/activejob/temporal/batch_enqueuer.rb +42 -33
- data/lib/activejob/temporal/bind_policy.rb +1 -1
- data/lib/activejob/temporal/cancel.rb +66 -29
- data/lib/activejob/temporal/certificate_watcher.rb +41 -6
- data/lib/activejob/temporal/client.rb +24 -6
- data/lib/activejob/temporal/conditional_enqueue.rb +2 -1
- data/lib/activejob/temporal/configurable.rb +56 -13
- data/lib/activejob/temporal/configuration.rb +218 -7
- data/lib/activejob/temporal/configured_job_compatibility.rb +91 -9
- data/lib/activejob/temporal/connection_worker_pool.rb +18 -1
- data/lib/activejob/temporal/dead_letter_queue.rb +64 -18
- data/lib/activejob/temporal/dependency_options.rb +126 -16
- data/lib/activejob/temporal/health_check_server.rb +14 -17
- data/lib/activejob/temporal/http_line_reader.rb +20 -2
- data/lib/activejob/temporal/http_request_failure_handling.rb +41 -0
- data/lib/activejob/temporal/inspect.rb +17 -7
- data/lib/activejob/temporal/job_id_validation.rb +41 -0
- data/lib/activejob/temporal/job_payload_dependencies.rb +23 -0
- data/lib/activejob/temporal/locales/en.yml +30 -3
- data/lib/activejob/temporal/metrics_server.rb +15 -18
- data/lib/activejob/temporal/middleware/chain.rb +7 -0
- data/lib/activejob/temporal/observability.rb +14 -2
- data/lib/activejob/temporal/payload.rb +64 -25
- data/lib/activejob/temporal/payload_encryption.rb +9 -1
- data/lib/activejob/temporal/payload_serializers.rb +3 -0
- data/lib/activejob/temporal/payload_storage.rb +4 -4
- data/lib/activejob/temporal/rails_environment_loader.rb +1 -8
- data/lib/activejob/temporal/reload_signal_queue.rb +19 -19
- data/lib/activejob/temporal/retry_handler_extractor.rb +18 -3
- data/lib/activejob/temporal/schedulable.rb +5 -7
- data/lib/activejob/temporal/schedule.rb +2 -2
- data/lib/activejob/temporal/signal_query.rb +35 -23
- data/lib/activejob/temporal/temporal_options.rb +26 -1
- data/lib/activejob/temporal/tls_file.rb +16 -16
- data/lib/activejob/temporal/transaction_safety.rb +102 -1
- data/lib/activejob/temporal/version.rb +1 -1
- data/lib/activejob/temporal/visibility_query.rb +16 -1
- data/lib/activejob/temporal/worker_pool.rb +13 -0
- data/lib/activejob/temporal/worker_registrations.rb +66 -0
- data/lib/activejob/temporal/worker_runtime.rb +17 -0
- data/lib/activejob/temporal/workflow_enqueuer.rb +2 -1
- data/lib/activejob/temporal/workflow_types.rb +10 -0
- data/lib/activejob/temporal/workflows/aj_workflow.rb +17 -4
- data/lib/activejob/temporal/workflows/workflow_dependencies.rb +148 -15
- data/lib/activejob/temporal.rb +21 -13
- data/test/mutant_unit_test.rb +13 -0
- metadata +46 -50
|
@@ -38,6 +38,8 @@ module ActiveJob
|
|
|
38
38
|
TARGET_HOST_LABEL_PATTERN = /\A[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\z/
|
|
39
39
|
TARGET_PORT_PATTERN = /\A[1-9]\d{0,4}\z/
|
|
40
40
|
NAMESPACE_PATTERN = /\A[A-Za-z0-9](?:[A-Za-z0-9_-]*[A-Za-z0-9])?\z/
|
|
41
|
+
ENV_BOOLEAN_TRUE_VALUES = %w[1 true yes on].freeze
|
|
42
|
+
ENV_BOOLEAN_FALSE_VALUES = %w[0 false no off].freeze
|
|
41
43
|
|
|
42
44
|
# Central registry of all configuration attributes.
|
|
43
45
|
#
|
|
@@ -134,6 +136,29 @@ module ActiveJob
|
|
|
134
136
|
description: "Signal used by workers to reload TLS certificates manually"
|
|
135
137
|
},
|
|
136
138
|
|
|
139
|
+
api_key: {
|
|
140
|
+
default: nil,
|
|
141
|
+
env_var: "ACTIVEJOB_TEMPORAL_API_KEY",
|
|
142
|
+
type: :string,
|
|
143
|
+
description: "Optional API key sent as the Authorization bearer token"
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
api_key_file: {
|
|
147
|
+
default: nil,
|
|
148
|
+
env_var: "ACTIVEJOB_TEMPORAL_API_KEY_FILE",
|
|
149
|
+
type: :string,
|
|
150
|
+
description: "Optional file read for the API key when the client is built, for example a " \
|
|
151
|
+
"projected Kubernetes ServiceAccount token. An explicit api_key takes precedence"
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
api_key_watch: {
|
|
155
|
+
default: false,
|
|
156
|
+
env_var: "ACTIVEJOB_TEMPORAL_API_KEY_WATCH",
|
|
157
|
+
type: :boolean,
|
|
158
|
+
description: "Watch api_key_file and reload worker clients when it changes (kubelet rewrites " \
|
|
159
|
+
"projected tokens in place long before they expire)"
|
|
160
|
+
},
|
|
161
|
+
|
|
137
162
|
priority_task_queues: {
|
|
138
163
|
default: -> { {} },
|
|
139
164
|
type: :hash,
|
|
@@ -311,6 +336,13 @@ module ActiveJob
|
|
|
311
336
|
description: "Base64-encoded 32-byte AES-256-GCM payload encryption key or key metadata"
|
|
312
337
|
},
|
|
313
338
|
|
|
339
|
+
allow_legacy_encrypted_payloads: {
|
|
340
|
+
default: false,
|
|
341
|
+
env_var: "ACTIVEJOB_TEMPORAL_ALLOW_LEGACY_ENCRYPTED_PAYLOADS",
|
|
342
|
+
type: :boolean,
|
|
343
|
+
description: "Accept version 1 encrypted payloads, which are not bound to a namespace/workflow context"
|
|
344
|
+
},
|
|
345
|
+
|
|
314
346
|
encryption_old_keys: {
|
|
315
347
|
default: -> { [] },
|
|
316
348
|
type: :array,
|
|
@@ -330,6 +362,34 @@ module ActiveJob
|
|
|
330
362
|
description: "Optional workflow history event threshold for continuing ActiveJob workflows as new"
|
|
331
363
|
},
|
|
332
364
|
|
|
365
|
+
dependency_wait_timeout: {
|
|
366
|
+
default: -> { 1.day },
|
|
367
|
+
env_var: "ACTIVEJOB_TEMPORAL_DEPENDENCY_WAIT_TIMEOUT_SECONDS",
|
|
368
|
+
type: :duration,
|
|
369
|
+
description: "Maximum time a workflow waits for dependencies before timing out"
|
|
370
|
+
},
|
|
371
|
+
|
|
372
|
+
dependency_wait_initial_interval: {
|
|
373
|
+
default: -> { 10.seconds },
|
|
374
|
+
env_var: "ACTIVEJOB_TEMPORAL_DEPENDENCY_WAIT_INITIAL_INTERVAL_SECONDS",
|
|
375
|
+
type: :duration,
|
|
376
|
+
description: "Initial durable sleep interval between dependency checks"
|
|
377
|
+
},
|
|
378
|
+
|
|
379
|
+
dependency_wait_max_interval: {
|
|
380
|
+
default: -> { 1.minute },
|
|
381
|
+
env_var: "ACTIVEJOB_TEMPORAL_DEPENDENCY_WAIT_MAX_INTERVAL_SECONDS",
|
|
382
|
+
type: :duration,
|
|
383
|
+
description: "Maximum durable sleep interval between dependency checks"
|
|
384
|
+
},
|
|
385
|
+
|
|
386
|
+
dependency_wait_backoff: {
|
|
387
|
+
default: 2.0,
|
|
388
|
+
env_var: "ACTIVEJOB_TEMPORAL_DEPENDENCY_WAIT_BACKOFF",
|
|
389
|
+
type: :float,
|
|
390
|
+
description: "Backoff coefficient for dependency polling intervals"
|
|
391
|
+
},
|
|
392
|
+
|
|
333
393
|
local_activity_helpers: {
|
|
334
394
|
default: -> { [] },
|
|
335
395
|
type: :array,
|
|
@@ -348,6 +408,29 @@ module ActiveJob
|
|
|
348
408
|
env_var: "ACTIVEJOB_TEMPORAL_MAX_CONCURRENT_WORKFLOW_TASKS",
|
|
349
409
|
type: :integer,
|
|
350
410
|
description: "Maximum concurrent workflow tasks per worker"
|
|
411
|
+
},
|
|
412
|
+
|
|
413
|
+
worker_activities: {
|
|
414
|
+
default: -> { [] },
|
|
415
|
+
type: :array,
|
|
416
|
+
description: "Additional activity classes (or class names) the worker registers, for example " \
|
|
417
|
+
"activities invoked by workflows owned by another service"
|
|
418
|
+
},
|
|
419
|
+
|
|
420
|
+
worker_activejob_workloads: {
|
|
421
|
+
default: true,
|
|
422
|
+
env_var: "ACTIVEJOB_TEMPORAL_WORKER_ACTIVEJOB_WORKLOADS",
|
|
423
|
+
type: :boolean,
|
|
424
|
+
description: "Register the built-in ActiveJob workflows and activities on the worker. Disable " \
|
|
425
|
+
"for workers that only host worker_activities"
|
|
426
|
+
},
|
|
427
|
+
|
|
428
|
+
graceful_shutdown_period: {
|
|
429
|
+
default: 0.0,
|
|
430
|
+
env_var: "ACTIVEJOB_TEMPORAL_GRACEFUL_SHUTDOWN_PERIOD",
|
|
431
|
+
type: :float,
|
|
432
|
+
description: "Seconds a shutting-down worker lets running activities finish before their " \
|
|
433
|
+
"tasks are cancelled (0 cancels immediately, matching the SDK default)"
|
|
351
434
|
}
|
|
352
435
|
}.freeze
|
|
353
436
|
|
|
@@ -357,13 +440,18 @@ module ActiveJob
|
|
|
357
440
|
# Use {ActiveJob::Temporal.configure} to set values with automatic validation.
|
|
358
441
|
#
|
|
359
442
|
# @note Thread Safety
|
|
360
|
-
# The global configuration object is synchronized by {Configurable}
|
|
361
|
-
#
|
|
443
|
+
# The global configuration object is synchronized by {Configurable}.
|
|
444
|
+
# Configure blocks mutate a candidate copy that replaces the active
|
|
445
|
+
# configuration only after validation succeeds.
|
|
362
446
|
#
|
|
363
447
|
# @note Environment Variable Defaults
|
|
364
448
|
# ACTIVEJOB_TEMPORAL_TARGET, ACTIVEJOB_TEMPORAL_NAMESPACE,
|
|
365
449
|
# ACTIVEJOB_TEMPORAL_TASK_QUEUE_PREFIX, ACTIVEJOB_TEMPORAL_TASK_QUEUE,
|
|
366
450
|
# ACTIVEJOB_TEMPORAL_MAX_PAYLOAD_SIZE_KB,
|
|
451
|
+
# ACTIVEJOB_TEMPORAL_DEPENDENCY_WAIT_TIMEOUT_SECONDS,
|
|
452
|
+
# ACTIVEJOB_TEMPORAL_DEPENDENCY_WAIT_INITIAL_INTERVAL_SECONDS,
|
|
453
|
+
# ACTIVEJOB_TEMPORAL_DEPENDENCY_WAIT_MAX_INTERVAL_SECONDS,
|
|
454
|
+
# ACTIVEJOB_TEMPORAL_DEPENDENCY_WAIT_BACKOFF,
|
|
367
455
|
# ACTIVEJOB_TEMPORAL_MAX_CONCURRENT_ACTIVITIES,
|
|
368
456
|
# ACTIVEJOB_TEMPORAL_MAX_CONCURRENT_WORKFLOW_TASKS,
|
|
369
457
|
# can provide defaults.
|
|
@@ -394,6 +482,14 @@ module ActiveJob
|
|
|
394
482
|
end
|
|
395
483
|
end
|
|
396
484
|
|
|
485
|
+
def initialize_copy(original)
|
|
486
|
+
super
|
|
487
|
+
@attributes = original.instance_variable_get(:@attributes).transform_values do |value|
|
|
488
|
+
duplicate_configuration_value(value)
|
|
489
|
+
end
|
|
490
|
+
@in_configure_block = false
|
|
491
|
+
end
|
|
492
|
+
|
|
397
493
|
def [](key)
|
|
398
494
|
@attributes[key]
|
|
399
495
|
end
|
|
@@ -406,6 +502,26 @@ module ActiveJob
|
|
|
406
502
|
middleware_chain.add(middleware, ...)
|
|
407
503
|
end
|
|
408
504
|
|
|
505
|
+
# Attributes holding secrets, redacted by {#inspect}.
|
|
506
|
+
REDACTED_ATTRIBUTES = %i[encryption_key encryption_old_keys tls api_key].freeze
|
|
507
|
+
REDACTED_PLACEHOLDER = "[FILTERED]"
|
|
508
|
+
|
|
509
|
+
# Returns the configuration with secret attributes redacted.
|
|
510
|
+
#
|
|
511
|
+
# The default `Object#inspect` dumps `@attributes`, which exposes the payload
|
|
512
|
+
# encryption keys and the inline TLS private key in logs and exception output.
|
|
513
|
+
#
|
|
514
|
+
# @return [String] inspect output with encryption keys and TLS settings filtered
|
|
515
|
+
def inspect
|
|
516
|
+
pairs = @attributes.map do |attribute, value|
|
|
517
|
+
value = REDACTED_PLACEHOLDER if REDACTED_ATTRIBUTES.include?(attribute) && value.present?
|
|
518
|
+
|
|
519
|
+
"#{attribute}=#{value.inspect}"
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
"#<#{self.class.name} #{pairs.join(', ')}>"
|
|
523
|
+
end
|
|
524
|
+
|
|
409
525
|
def validate!
|
|
410
526
|
return if validation_level == :none
|
|
411
527
|
|
|
@@ -415,6 +531,11 @@ module ActiveJob
|
|
|
415
531
|
handle_validation_errors(validator.errors)
|
|
416
532
|
end
|
|
417
533
|
|
|
534
|
+
def finalize_configuration_copy!
|
|
535
|
+
observability.finalize_configuration_copy! if observability.respond_to?(:finalize_configuration_copy!)
|
|
536
|
+
self
|
|
537
|
+
end
|
|
538
|
+
|
|
418
539
|
def self.format_validation_errors(errors)
|
|
419
540
|
messages = errors.full_messages
|
|
420
541
|
return messages.first if messages.size == 1
|
|
@@ -429,6 +550,19 @@ module ActiveJob
|
|
|
429
550
|
|
|
430
551
|
private
|
|
431
552
|
|
|
553
|
+
def duplicate_configuration_value(value)
|
|
554
|
+
case value
|
|
555
|
+
when Hash
|
|
556
|
+
value.transform_values { |entry| duplicate_configuration_value(entry) }
|
|
557
|
+
when Array
|
|
558
|
+
value.map { |entry| duplicate_configuration_value(entry) }
|
|
559
|
+
when String, Middleware::Chain, Observability::Configuration
|
|
560
|
+
value.dup
|
|
561
|
+
else
|
|
562
|
+
value
|
|
563
|
+
end
|
|
564
|
+
end
|
|
565
|
+
|
|
432
566
|
def build_validator
|
|
433
567
|
validator = ConfigValidator.new
|
|
434
568
|
|
|
@@ -448,24 +582,37 @@ module ActiveJob
|
|
|
448
582
|
end
|
|
449
583
|
|
|
450
584
|
def resolve_default_value(metadata)
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
585
|
+
env_var = metadata[:env_var]
|
|
586
|
+
if env_var && ENV[env_var]
|
|
587
|
+
value = ENV[env_var]
|
|
588
|
+
return convert_env_value(value, metadata[:type], env_var)
|
|
454
589
|
end
|
|
455
590
|
|
|
456
591
|
default = metadata[:default]
|
|
457
592
|
default.is_a?(Proc) ? default.call : default
|
|
458
593
|
end
|
|
459
594
|
|
|
460
|
-
def convert_env_value(value, type)
|
|
595
|
+
def convert_env_value(value, type, env_var)
|
|
461
596
|
case type
|
|
462
597
|
when :integer then value.to_i
|
|
463
598
|
when :float, :duration then value.to_f
|
|
464
|
-
when :boolean then value
|
|
599
|
+
when :boolean then convert_env_boolean(value, env_var)
|
|
465
600
|
when :symbol then value.to_sym
|
|
466
601
|
else value
|
|
467
602
|
end
|
|
468
603
|
end
|
|
604
|
+
|
|
605
|
+
def convert_env_boolean(value, env_var)
|
|
606
|
+
normalized = value.to_s.strip.downcase
|
|
607
|
+
|
|
608
|
+
return true if ENV_BOOLEAN_TRUE_VALUES.include?(normalized)
|
|
609
|
+
return false if ENV_BOOLEAN_FALSE_VALUES.include?(normalized)
|
|
610
|
+
|
|
611
|
+
raise ConfigurationError,
|
|
612
|
+
"Invalid boolean value for #{env_var}: #{value.inspect}. " \
|
|
613
|
+
"Accepted true values: #{ENV_BOOLEAN_TRUE_VALUES.join(', ')}. " \
|
|
614
|
+
"Accepted false values: #{ENV_BOOLEAN_FALSE_VALUES.join(', ')}."
|
|
615
|
+
end
|
|
469
616
|
end
|
|
470
617
|
|
|
471
618
|
# Validates ActiveJob::Temporal configuration.
|
|
@@ -560,6 +707,13 @@ module ActiveJob
|
|
|
560
707
|
allow_nil: false
|
|
561
708
|
}
|
|
562
709
|
|
|
710
|
+
validates :graceful_shutdown_period,
|
|
711
|
+
numericality: {
|
|
712
|
+
greater_than_or_equal_to: 0,
|
|
713
|
+
message: :graceful_shutdown_period_negative,
|
|
714
|
+
allow_nil: false
|
|
715
|
+
}
|
|
716
|
+
|
|
563
717
|
validates :continue_as_new_history_event_threshold,
|
|
564
718
|
numericality: {
|
|
565
719
|
greater_than: 0,
|
|
@@ -597,7 +751,10 @@ module ActiveJob
|
|
|
597
751
|
validate :validate_encryption_settings
|
|
598
752
|
validate :validate_payload_storage_settings
|
|
599
753
|
validate :validate_tls_settings
|
|
754
|
+
validate :validate_api_key_settings
|
|
755
|
+
validate :validate_worker_registration_settings
|
|
600
756
|
validate :validate_local_activity_helpers
|
|
757
|
+
validate :validate_dependency_wait_settings
|
|
601
758
|
|
|
602
759
|
private
|
|
603
760
|
|
|
@@ -645,6 +802,30 @@ module ActiveJob
|
|
|
645
802
|
validate_duration_attribute(:default_schedule_to_start_timeout, required: false)
|
|
646
803
|
validate_duration_attribute(:default_schedule_to_close_timeout, required: false)
|
|
647
804
|
validate_duration_attribute(:dead_letter_auto_discard_after, required: false)
|
|
805
|
+
validate_duration_attribute(:dependency_wait_timeout, required: true)
|
|
806
|
+
validate_duration_attribute(:dependency_wait_initial_interval, required: true)
|
|
807
|
+
validate_duration_attribute(:dependency_wait_max_interval, required: true)
|
|
808
|
+
end
|
|
809
|
+
|
|
810
|
+
def validate_dependency_wait_settings
|
|
811
|
+
validate_dependency_wait_backoff
|
|
812
|
+
validate_dependency_wait_interval_order
|
|
813
|
+
end
|
|
814
|
+
|
|
815
|
+
def validate_dependency_wait_backoff
|
|
816
|
+
value = dependency_wait_backoff
|
|
817
|
+
return if value.is_a?(Numeric) && value >= 1.0
|
|
818
|
+
|
|
819
|
+
errors.add(:dependency_wait_backoff, "must be greater than or equal to 1")
|
|
820
|
+
end
|
|
821
|
+
|
|
822
|
+
def validate_dependency_wait_interval_order
|
|
823
|
+
initial_interval = duration_seconds(dependency_wait_initial_interval)
|
|
824
|
+
max_interval = duration_seconds(dependency_wait_max_interval)
|
|
825
|
+
return unless initial_interval && max_interval
|
|
826
|
+
return if max_interval >= initial_interval
|
|
827
|
+
|
|
828
|
+
errors.add(:dependency_wait_max_interval, "must be greater than or equal to initial interval")
|
|
648
829
|
end
|
|
649
830
|
|
|
650
831
|
def validate_workflow_id_generator
|
|
@@ -898,6 +1079,30 @@ module ActiveJob
|
|
|
898
1079
|
errors.add(:tls_cert_watch, :requires_paths)
|
|
899
1080
|
end
|
|
900
1081
|
|
|
1082
|
+
def validate_api_key_settings
|
|
1083
|
+
validate_tls_file_path(:api_key_file)
|
|
1084
|
+
|
|
1085
|
+
unless [true, false].include?(api_key_watch)
|
|
1086
|
+
errors.add(:api_key_watch, :not_boolean, value: api_key_watch.inspect)
|
|
1087
|
+
return
|
|
1088
|
+
end
|
|
1089
|
+
|
|
1090
|
+
return unless api_key_watch && api_key_file.to_s.strip.empty?
|
|
1091
|
+
|
|
1092
|
+
errors.add(:api_key_watch, :requires_path)
|
|
1093
|
+
end
|
|
1094
|
+
|
|
1095
|
+
def validate_worker_registration_settings
|
|
1096
|
+
unless [true, false].include?(worker_activejob_workloads)
|
|
1097
|
+
errors.add(:worker_activejob_workloads, :not_boolean, value: worker_activejob_workloads.inspect)
|
|
1098
|
+
return
|
|
1099
|
+
end
|
|
1100
|
+
|
|
1101
|
+
return if worker_activejob_workloads || Array(worker_activities).any?
|
|
1102
|
+
|
|
1103
|
+
errors.add(:worker_activejob_workloads, :requires_activities)
|
|
1104
|
+
end
|
|
1105
|
+
|
|
901
1106
|
def validate_tls_reload_signal
|
|
902
1107
|
unless tls_reload_signal.is_a?(String) && tls_reload_signal.strip.present?
|
|
903
1108
|
errors.add(:tls_reload_signal, :blank)
|
|
@@ -974,6 +1179,12 @@ module ActiveJob
|
|
|
974
1179
|
seconds: seconds,
|
|
975
1180
|
attribute: attr_name.to_s.humanize.downcase)
|
|
976
1181
|
end
|
|
1182
|
+
|
|
1183
|
+
def duration_seconds(value)
|
|
1184
|
+
return value.to_f if value.is_a?(Numeric) || value.is_a?(ActiveSupport::Duration)
|
|
1185
|
+
|
|
1186
|
+
nil
|
|
1187
|
+
end
|
|
977
1188
|
end
|
|
978
1189
|
# rubocop:enable Metrics/ClassLength
|
|
979
1190
|
end
|
|
@@ -6,39 +6,121 @@ require_relative "logger"
|
|
|
6
6
|
module ActiveJob
|
|
7
7
|
module Temporal
|
|
8
8
|
module ConfiguredJobCompatibility
|
|
9
|
+
SUPPORTED_ACTIVE_JOB_MAJOR_MINOR = [
|
|
10
|
+
[7, 2],
|
|
11
|
+
[8, 0],
|
|
12
|
+
[8, 1]
|
|
13
|
+
].freeze
|
|
14
|
+
FEATURE_REPLACEMENTS = {
|
|
15
|
+
"chain" => "ActiveJob::Temporal.job",
|
|
16
|
+
"child_workflows" => "ActiveJob::Temporal.job",
|
|
17
|
+
"conditional_enqueue" => "JobClass.perform_later_if or an explicit condition before " \
|
|
18
|
+
"JobClass.set(...).perform_later"
|
|
19
|
+
}.freeze
|
|
20
|
+
ExtractedConfiguredJob = Struct.new(:job_class, :options, keyword_init: true)
|
|
21
|
+
|
|
9
22
|
module_function
|
|
10
23
|
|
|
11
24
|
def payload(value, feature:, normalize_options:)
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
log_private_api(feature)
|
|
15
|
-
|
|
16
|
-
job_class = value.instance_variable_get(:@job_class)
|
|
17
|
-
return unless active_job_class?(job_class)
|
|
25
|
+
configured_job = extract(value, feature: feature)
|
|
26
|
+
return unless configured_job
|
|
18
27
|
|
|
19
28
|
{
|
|
20
|
-
job_class: job_class.name,
|
|
21
|
-
options: normalize_options.call(
|
|
29
|
+
job_class: configured_job.job_class.name,
|
|
30
|
+
options: normalize_options.call(configured_job.options)
|
|
22
31
|
}
|
|
23
32
|
end
|
|
24
33
|
|
|
34
|
+
def job_class(value, feature:)
|
|
35
|
+
extract(value, feature: feature)&.job_class
|
|
36
|
+
end
|
|
37
|
+
|
|
25
38
|
def configured_job?(value)
|
|
26
39
|
defined?(ActiveJob::ConfiguredJob) && value.is_a?(ActiveJob::ConfiguredJob)
|
|
27
40
|
end
|
|
28
41
|
|
|
42
|
+
def extract(value, feature:)
|
|
43
|
+
return unless configured_job?(value)
|
|
44
|
+
|
|
45
|
+
validate_active_job_version!(feature)
|
|
46
|
+
|
|
47
|
+
job_class = configured_job_instance_variable(value, :@job_class, feature)
|
|
48
|
+
options = configured_job_instance_variable(value, :@options, feature)
|
|
49
|
+
|
|
50
|
+
validate_job_class!(job_class, feature)
|
|
51
|
+
validate_options!(options, feature)
|
|
52
|
+
|
|
53
|
+
log_private_api(feature)
|
|
54
|
+
|
|
55
|
+
ExtractedConfiguredJob.new(job_class: job_class, options: options.dup)
|
|
56
|
+
end
|
|
57
|
+
private_class_method :extract
|
|
58
|
+
|
|
29
59
|
def active_job_class?(job_class)
|
|
30
60
|
job_class.is_a?(Class) && job_class < ActiveJob::Base && job_class.name
|
|
31
61
|
end
|
|
32
62
|
|
|
63
|
+
def validate_job_class!(job_class, feature)
|
|
64
|
+
return if active_job_class?(job_class)
|
|
65
|
+
|
|
66
|
+
raise ArgumentError, unsupported_internals_message(feature, "@job_class")
|
|
67
|
+
end
|
|
68
|
+
private_class_method :validate_job_class!
|
|
69
|
+
|
|
70
|
+
def validate_options!(options, feature)
|
|
71
|
+
return if options.is_a?(Hash)
|
|
72
|
+
|
|
73
|
+
raise ArgumentError, unsupported_internals_message(feature, "@options")
|
|
74
|
+
end
|
|
75
|
+
private_class_method :validate_options!
|
|
76
|
+
|
|
77
|
+
def configured_job_instance_variable(value, name, feature)
|
|
78
|
+
return value.instance_variable_get(name) if value.instance_variable_defined?(name)
|
|
79
|
+
|
|
80
|
+
raise ArgumentError, unsupported_internals_message(feature, name)
|
|
81
|
+
end
|
|
82
|
+
private_class_method :configured_job_instance_variable
|
|
83
|
+
|
|
84
|
+
def validate_active_job_version!(feature)
|
|
85
|
+
return if supported_active_job_version?
|
|
86
|
+
|
|
87
|
+
raise ArgumentError,
|
|
88
|
+
"ActiveJob::ConfiguredJob internals are not supported for #{feature} on ActiveJob " \
|
|
89
|
+
"#{active_job_version}; use #{replacement_for(feature)} instead"
|
|
90
|
+
end
|
|
91
|
+
private_class_method :validate_active_job_version!
|
|
92
|
+
|
|
93
|
+
def supported_active_job_version?
|
|
94
|
+
SUPPORTED_ACTIVE_JOB_MAJOR_MINOR.include?(active_job_version.segments.first(2))
|
|
95
|
+
end
|
|
96
|
+
private_class_method :supported_active_job_version?
|
|
97
|
+
|
|
98
|
+
def active_job_version
|
|
99
|
+
return ActiveJob.gem_version if ActiveJob.respond_to?(:gem_version)
|
|
100
|
+
|
|
101
|
+
Gem::Version.new(ActiveJob::VERSION::STRING)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def unsupported_internals_message(feature, name)
|
|
105
|
+
"ActiveJob::ConfiguredJob internals changed for #{feature}: expected #{name}; " \
|
|
106
|
+
"use #{replacement_for(feature)} instead"
|
|
107
|
+
end
|
|
108
|
+
private_class_method :unsupported_internals_message
|
|
109
|
+
|
|
33
110
|
def log_private_api(feature)
|
|
34
111
|
ActiveJob::Temporal::Logger.warn(
|
|
35
112
|
"active_job_configured_job_private_api",
|
|
36
113
|
feature: feature,
|
|
37
|
-
replacement:
|
|
114
|
+
replacement: replacement_for(feature)
|
|
38
115
|
)
|
|
39
116
|
rescue StandardError
|
|
40
117
|
nil
|
|
41
118
|
end
|
|
119
|
+
|
|
120
|
+
def replacement_for(feature)
|
|
121
|
+
FEATURE_REPLACEMENTS.fetch(feature.to_s, "ActiveJob::Temporal.job")
|
|
122
|
+
end
|
|
123
|
+
private_class_method :replacement_for
|
|
42
124
|
end
|
|
43
125
|
end
|
|
44
126
|
end
|
|
@@ -72,12 +72,29 @@ module ActiveJob
|
|
|
72
72
|
connection = queue.pop
|
|
73
73
|
break unless connection
|
|
74
74
|
|
|
75
|
-
|
|
75
|
+
begin
|
|
76
|
+
@handler.call(connection)
|
|
77
|
+
rescue StandardError => e
|
|
78
|
+
log_handler_failure(index, e)
|
|
79
|
+
close_connection(connection)
|
|
80
|
+
end
|
|
76
81
|
end
|
|
77
82
|
rescue ClosedQueueError
|
|
78
83
|
nil
|
|
79
84
|
end
|
|
80
85
|
|
|
86
|
+
def log_handler_failure(index, error)
|
|
87
|
+
ActiveJob::Temporal::Logger.error(
|
|
88
|
+
"connection_worker_handler_failed",
|
|
89
|
+
pool: @name,
|
|
90
|
+
worker_index: index,
|
|
91
|
+
error_class: error.class.name,
|
|
92
|
+
message: error.message.to_s
|
|
93
|
+
)
|
|
94
|
+
rescue StandardError
|
|
95
|
+
nil
|
|
96
|
+
end
|
|
97
|
+
|
|
81
98
|
def close_connection(connection)
|
|
82
99
|
connection.close
|
|
83
100
|
rescue IOError, SystemCallError
|
|
@@ -2,11 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
require "temporalio/client"
|
|
4
4
|
|
|
5
|
+
require_relative "logger"
|
|
6
|
+
require_relative "visibility_query"
|
|
7
|
+
require_relative "workflow_types"
|
|
8
|
+
|
|
5
9
|
module ActiveJob
|
|
6
10
|
module Temporal
|
|
7
11
|
module DeadLetterQueue
|
|
8
|
-
WORKFLOW_TYPE =
|
|
12
|
+
WORKFLOW_TYPE = WorkflowTypes::DEAD_LETTER
|
|
9
13
|
DEFAULT_ENTRIES_LIMIT = 100
|
|
14
|
+
ENTRY_QUERY_CONCURRENCY = 5
|
|
10
15
|
|
|
11
16
|
module_function
|
|
12
17
|
|
|
@@ -17,22 +22,24 @@ module ActiveJob
|
|
|
17
22
|
def entries(queue: nil, limit: DEFAULT_ENTRIES_LIMIT, client: ActiveJob::Temporal.client)
|
|
18
23
|
validate_limit!(limit)
|
|
19
24
|
|
|
20
|
-
client.list_workflows(entries_query(queue)).
|
|
21
|
-
|
|
22
|
-
entries << entry if entry
|
|
23
|
-
break entries if entries.size >= limit
|
|
24
|
-
end
|
|
25
|
+
workflows = client.list_workflows(entries_query(queue)).first(limit)
|
|
26
|
+
query_workflow_entries(client, workflows)
|
|
25
27
|
end
|
|
26
28
|
|
|
27
29
|
def retry(job_class, job_id, queue: nil, client: ActiveJob::Temporal.client)
|
|
28
30
|
handle = handle_for(job_class, job_id, client: client)
|
|
29
31
|
entry = handle.query(:entry)
|
|
30
|
-
|
|
32
|
+
if retried_entry?(entry)
|
|
33
|
+
workflow_id = entry.fetch("retry_workflow_id")
|
|
34
|
+
log_retry_requested(entry, workflow_id, queue, duplicate: true)
|
|
35
|
+
return workflow_id
|
|
36
|
+
end
|
|
31
37
|
|
|
32
38
|
ensure_pending_entry!(entry)
|
|
33
39
|
|
|
34
40
|
workflow_id = retry_workflow_id(entry)
|
|
35
|
-
start_retry_workflow(client, entry, workflow_id, queue)
|
|
41
|
+
duplicate = start_retry_workflow(client, entry, workflow_id, queue)
|
|
42
|
+
log_retry_requested(entry, workflow_id, queue, duplicate: duplicate)
|
|
36
43
|
mark_retried_entry(handle, workflow_id)
|
|
37
44
|
workflow_id
|
|
38
45
|
end
|
|
@@ -56,31 +63,72 @@ module ActiveJob
|
|
|
56
63
|
|
|
57
64
|
def entries_query(queue)
|
|
58
65
|
query = ["WorkflowType='#{WORKFLOW_TYPE}'", "ExecutionStatus='Running'"]
|
|
59
|
-
query << "TaskQueue
|
|
66
|
+
query << "TaskQueue=#{VisibilityQuery.quote(queue)}" if queue.to_s.strip.present?
|
|
60
67
|
query.join(" AND ")
|
|
61
68
|
end
|
|
62
69
|
private_class_method :entries_query
|
|
63
70
|
|
|
71
|
+
def query_workflow_entries(client, workflows)
|
|
72
|
+
entries = Array.new(workflows.size)
|
|
73
|
+
pending = Queue.new
|
|
74
|
+
workflows.each_with_index { |workflow, index| pending << [workflow, index] }
|
|
75
|
+
|
|
76
|
+
Array.new([workflows.size, ENTRY_QUERY_CONCURRENCY].min) do
|
|
77
|
+
worker = Thread.new do
|
|
78
|
+
loop do
|
|
79
|
+
workflow, index = pending.pop(true)
|
|
80
|
+
entries[index] = query_workflow_entry(client, workflow)
|
|
81
|
+
rescue ThreadError
|
|
82
|
+
break
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
# Failures are re-raised by Thread#value below, not lost.
|
|
86
|
+
worker.report_on_exception = false
|
|
87
|
+
worker
|
|
88
|
+
end.each(&:value)
|
|
89
|
+
|
|
90
|
+
entries
|
|
91
|
+
end
|
|
92
|
+
private_class_method :query_workflow_entries
|
|
93
|
+
|
|
64
94
|
def query_workflow_entry(client, workflow)
|
|
65
95
|
client.workflow_handle(workflow.id, run_id: workflow_run_id(workflow)).query(:entry)
|
|
66
|
-
rescue Temporalio::Error
|
|
67
|
-
nil
|
|
68
96
|
end
|
|
69
97
|
private_class_method :query_workflow_entry
|
|
70
98
|
|
|
71
99
|
def start_retry_workflow(client, entry, workflow_id, queue)
|
|
72
100
|
client.start_workflow(
|
|
73
|
-
|
|
101
|
+
WorkflowTypes::ACTIVE_JOB,
|
|
74
102
|
retry_payload(entry),
|
|
75
103
|
id: workflow_id,
|
|
76
104
|
task_queue: retry_task_queue(entry, queue),
|
|
77
105
|
id_conflict_policy: Temporalio::WorkflowIDConflictPolicy::FAIL
|
|
78
106
|
)
|
|
107
|
+
false
|
|
79
108
|
rescue StandardError => e
|
|
80
109
|
raise unless workflow_already_started?(e)
|
|
110
|
+
|
|
111
|
+
true
|
|
81
112
|
end
|
|
82
113
|
private_class_method :start_retry_workflow
|
|
83
114
|
|
|
115
|
+
def log_retry_requested(entry, workflow_id, queue, duplicate:)
|
|
116
|
+
Logger.log_event(
|
|
117
|
+
"dead_letter_retry_requested",
|
|
118
|
+
{
|
|
119
|
+
entry_id: entry.fetch("id"),
|
|
120
|
+
workflow_id: workflow_id,
|
|
121
|
+
job_class: entry.dig("payload", "job_class"),
|
|
122
|
+
job_id: entry.dig("payload", "job_id"),
|
|
123
|
+
task_queue: retry_task_queue(entry, queue),
|
|
124
|
+
duplicate: duplicate
|
|
125
|
+
}.compact
|
|
126
|
+
)
|
|
127
|
+
rescue StandardError
|
|
128
|
+
nil
|
|
129
|
+
end
|
|
130
|
+
private_class_method :log_retry_requested
|
|
131
|
+
|
|
84
132
|
def mark_retried_entry(handle, workflow_id)
|
|
85
133
|
handle.signal(:mark_retried, workflow_id)
|
|
86
134
|
rescue StandardError => e
|
|
@@ -146,16 +194,14 @@ module ActiveJob
|
|
|
146
194
|
end
|
|
147
195
|
private_class_method :validate_limit!
|
|
148
196
|
|
|
149
|
-
def escape_query_value(value)
|
|
150
|
-
value.to_s.gsub("'", "''")
|
|
151
|
-
end
|
|
152
|
-
private_class_method :escape_query_value
|
|
153
|
-
|
|
154
197
|
def workflow_already_started?(error)
|
|
155
198
|
(defined?(Temporalio::Error::WorkflowAlreadyStartedError) &&
|
|
156
199
|
error.is_a?(Temporalio::Error::WorkflowAlreadyStartedError)) ||
|
|
157
200
|
(defined?(Temporalio::Client::WorkflowAlreadyStartedError) &&
|
|
158
|
-
error.is_a?(Temporalio::Client::WorkflowAlreadyStartedError))
|
|
201
|
+
error.is_a?(Temporalio::Client::WorkflowAlreadyStartedError)) ||
|
|
202
|
+
(defined?(Temporalio::Error::RPCError::Code::ALREADY_EXISTS) &&
|
|
203
|
+
error.respond_to?(:code) &&
|
|
204
|
+
error.code == Temporalio::Error::RPCError::Code::ALREADY_EXISTS)
|
|
159
205
|
end
|
|
160
206
|
private_class_method :workflow_already_started?
|
|
161
207
|
end
|