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.
Files changed (99) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +130 -0
  3. data/LICENSE +21 -0
  4. data/README.md +198 -0
  5. data/activejob-temporal.gemspec +58 -0
  6. data/api/job_payload_schema.json +318 -0
  7. data/bin/temporal-worker +295 -0
  8. data/lib/activejob/temporal/active_job_handler_source.rb +84 -0
  9. data/lib/activejob/temporal/activities/aj_runner_activity.rb +454 -0
  10. data/lib/activejob/temporal/activities/best_effort_side_effects.rb +49 -0
  11. data/lib/activejob/temporal/activities/dependency_status_activity.rb +160 -0
  12. data/lib/activejob/temporal/activities/rate_limit_activity.rb +41 -0
  13. data/lib/activejob/temporal/adapter.rb +257 -0
  14. data/lib/activejob/temporal/audit_log.rb +118 -0
  15. data/lib/activejob/temporal/batch_enqueue_result.rb +110 -0
  16. data/lib/activejob/temporal/batch_enqueuer.rb +141 -0
  17. data/lib/activejob/temporal/bind_policy.rb +44 -0
  18. data/lib/activejob/temporal/cancel/batch_canceller.rb +154 -0
  19. data/lib/activejob/temporal/cancel/batch_summary.rb +45 -0
  20. data/lib/activejob/temporal/cancel.rb +236 -0
  21. data/lib/activejob/temporal/certificate_watcher.rb +76 -0
  22. data/lib/activejob/temporal/chain_options.rb +83 -0
  23. data/lib/activejob/temporal/child_workflow_options.rb +102 -0
  24. data/lib/activejob/temporal/client.rb +215 -0
  25. data/lib/activejob/temporal/conditional_enqueue.rb +56 -0
  26. data/lib/activejob/temporal/configurable.rb +55 -0
  27. data/lib/activejob/temporal/configuration.rb +981 -0
  28. data/lib/activejob/temporal/configured_job_compatibility.rb +44 -0
  29. data/lib/activejob/temporal/connection_worker_pool.rb +88 -0
  30. data/lib/activejob/temporal/dead_letter_payload_validation.rb +34 -0
  31. data/lib/activejob/temporal/dead_letter_queue.rb +163 -0
  32. data/lib/activejob/temporal/dependency_options.rb +134 -0
  33. data/lib/activejob/temporal/external_operation.rb +193 -0
  34. data/lib/activejob/temporal/health_check_server.rb +159 -0
  35. data/lib/activejob/temporal/http_line_reader.rb +36 -0
  36. data/lib/activejob/temporal/inspect.rb +184 -0
  37. data/lib/activejob/temporal/job_descriptor.rb +37 -0
  38. data/lib/activejob/temporal/job_payload_builder.rb +209 -0
  39. data/lib/activejob/temporal/job_payload_chain_builder.rb +106 -0
  40. data/lib/activejob/temporal/job_payload_child_workflows.rb +127 -0
  41. data/lib/activejob/temporal/job_payload_dependencies.rb +40 -0
  42. data/lib/activejob/temporal/job_payload_rate_limits.rb +53 -0
  43. data/lib/activejob/temporal/job_payload_workflow_interactions.rb +31 -0
  44. data/lib/activejob/temporal/job_tags.rb +40 -0
  45. data/lib/activejob/temporal/locales/en.yml +126 -0
  46. data/lib/activejob/temporal/logger.rb +214 -0
  47. data/lib/activejob/temporal/metrics_server.rb +150 -0
  48. data/lib/activejob/temporal/middleware/chain.rb +106 -0
  49. data/lib/activejob/temporal/middleware.rb +11 -0
  50. data/lib/activejob/temporal/observability/datadog.rb +167 -0
  51. data/lib/activejob/temporal/observability/opentelemetry.rb +107 -0
  52. data/lib/activejob/temporal/observability/prometheus.rb +271 -0
  53. data/lib/activejob/temporal/observability.rb +260 -0
  54. data/lib/activejob/temporal/payload.rb +415 -0
  55. data/lib/activejob/temporal/payload_encryption.rb +215 -0
  56. data/lib/activejob/temporal/payload_serializers/json.rb +23 -0
  57. data/lib/activejob/temporal/payload_serializers/marshal.rb +53 -0
  58. data/lib/activejob/temporal/payload_serializers/message_pack.rb +59 -0
  59. data/lib/activejob/temporal/payload_serializers.rb +37 -0
  60. data/lib/activejob/temporal/payload_storage.rb +103 -0
  61. data/lib/activejob/temporal/rails_environment_loader.rb +143 -0
  62. data/lib/activejob/temporal/rate_limit_options.rb +94 -0
  63. data/lib/activejob/temporal/rate_limiters/memory.rb +198 -0
  64. data/lib/activejob/temporal/reload_signal_queue.rb +40 -0
  65. data/lib/activejob/temporal/retry_handler_extractor.rb +361 -0
  66. data/lib/activejob/temporal/retry_mapper.rb +264 -0
  67. data/lib/activejob/temporal/schedulable.rb +60 -0
  68. data/lib/activejob/temporal/schedule.rb +181 -0
  69. data/lib/activejob/temporal/schedule_options.rb +105 -0
  70. data/lib/activejob/temporal/search_attributes.rb +173 -0
  71. data/lib/activejob/temporal/signal_query.rb +161 -0
  72. data/lib/activejob/temporal/signal_query_options.rb +106 -0
  73. data/lib/activejob/temporal/temporal_options.rb +114 -0
  74. data/lib/activejob/temporal/tls_file.rb +45 -0
  75. data/lib/activejob/temporal/transaction_safety.rb +39 -0
  76. data/lib/activejob/temporal/version.rb +7 -0
  77. data/lib/activejob/temporal/visibility_query.rb +13 -0
  78. data/lib/activejob/temporal/worker_client_reloader.rb +34 -0
  79. data/lib/activejob/temporal/worker_health.rb +117 -0
  80. data/lib/activejob/temporal/worker_pool.rb +408 -0
  81. data/lib/activejob/temporal/workflow_enqueuer.rb +271 -0
  82. data/lib/activejob/temporal/workflow_enqueuer_batch.rb +17 -0
  83. data/lib/activejob/temporal/workflow_id_builder.rb +155 -0
  84. data/lib/activejob/temporal/workflow_identity.rb +62 -0
  85. data/lib/activejob/temporal/workflows/aj_workflow.rb +282 -0
  86. data/lib/activejob/temporal/workflows/dead_letter_support.rb +134 -0
  87. data/lib/activejob/temporal/workflows/dead_letter_workflow.rb +114 -0
  88. data/lib/activejob/temporal/workflows/workflow_chaining.rb +194 -0
  89. data/lib/activejob/temporal/workflows/workflow_child_workflows.rb +140 -0
  90. data/lib/activejob/temporal/workflows/workflow_continue_as_new.rb +44 -0
  91. data/lib/activejob/temporal/workflows/workflow_dependencies.rb +115 -0
  92. data/lib/activejob/temporal/workflows/workflow_execution_steps.rb +22 -0
  93. data/lib/activejob/temporal/workflows/workflow_interactions.rb +215 -0
  94. data/lib/activejob/temporal/workflows/workflow_local_activities.rb +29 -0
  95. data/lib/activejob/temporal/workflows/workflow_nexus.rb +15 -0
  96. data/lib/activejob/temporal/workflows/workflow_versioning.rb +21 -0
  97. data/lib/activejob/temporal.rb +297 -0
  98. data/lib/activejob-temporal.rb +3 -0
  99. metadata +423 -0
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+
5
+ module ActiveJob
6
+ module Temporal
7
+ module PayloadSerializers
8
+ module Marshal
9
+ extend self
10
+
11
+ NAME = "marshal"
12
+
13
+ def dump(payload)
14
+ envelope(Base64.strict_encode64(::Marshal.dump(payload)))
15
+ end
16
+
17
+ def load(payload)
18
+ # Marshal support is opt-in and only safe for trusted Temporal histories.
19
+ # rubocop:disable Security/MarshalLoad
20
+ normalize_top_level_keys(::Marshal.load(serialized_data(payload)))
21
+ # rubocop:enable Security/MarshalLoad
22
+ rescue StandardError => e
23
+ raise ActiveJob::SerializationError, "Unable to deserialize Marshal payload: #{e.message}"
24
+ end
25
+
26
+ def envelope?(payload)
27
+ (payload[:payload_serializer] || payload["payload_serializer"]) == NAME
28
+ end
29
+
30
+ private
31
+
32
+ def envelope(serialized_data)
33
+ {
34
+ serialized_payload: true,
35
+ payload_serializer: NAME,
36
+ payload_serializer_version: PayloadSerializers::ENVELOPE_VERSION,
37
+ serialized_data: serialized_data
38
+ }
39
+ end
40
+
41
+ def serialized_data(payload)
42
+ Base64.strict_decode64(payload[:serialized_data] || payload["serialized_data"])
43
+ end
44
+
45
+ def normalize_top_level_keys(payload)
46
+ payload.each_with_object({}) do |(key, value), normalized|
47
+ normalized[key.to_sym] = value
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+
5
+ module ActiveJob
6
+ module Temporal
7
+ module PayloadSerializers
8
+ module MessagePack
9
+ extend self
10
+
11
+ NAME = "message_pack"
12
+
13
+ def dump(payload)
14
+ envelope(Base64.strict_encode64(message_pack.pack(payload)))
15
+ end
16
+
17
+ def load(payload)
18
+ normalize_top_level_keys(message_pack.unpack(serialized_data(payload)))
19
+ rescue StandardError => e
20
+ raise if e.is_a?(ConfigurationError)
21
+
22
+ raise ActiveJob::SerializationError, "Unable to deserialize MessagePack payload: #{e.message}"
23
+ end
24
+
25
+ def envelope?(payload)
26
+ (payload[:payload_serializer] || payload["payload_serializer"]) == NAME
27
+ end
28
+
29
+ private
30
+
31
+ def envelope(serialized_data)
32
+ {
33
+ serialized_payload: true,
34
+ payload_serializer: NAME,
35
+ payload_serializer_version: PayloadSerializers::ENVELOPE_VERSION,
36
+ serialized_data: serialized_data
37
+ }
38
+ end
39
+
40
+ def serialized_data(payload)
41
+ Base64.strict_decode64(payload[:serialized_data] || payload["serialized_data"])
42
+ end
43
+
44
+ def message_pack
45
+ require "msgpack"
46
+ ::MessagePack
47
+ rescue LoadError
48
+ raise ConfigurationError, 'MessagePack payload serialization requires applications to add gem "msgpack"'
49
+ end
50
+
51
+ def normalize_top_level_keys(payload)
52
+ payload.each_with_object({}) do |(key, value), normalized|
53
+ normalized[key.to_sym] = value
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "payload_serializers/json"
4
+ require_relative "payload_serializers/marshal"
5
+ require_relative "payload_serializers/message_pack"
6
+
7
+ module ActiveJob
8
+ module Temporal
9
+ module PayloadSerializers
10
+ module_function
11
+
12
+ ENVELOPE_VERSION = 1
13
+ JSON = :json
14
+ MESSAGE_PACK = :message_pack
15
+ MESSAGE_PACK_ALIAS = :msgpack
16
+ MARSHAL = :marshal
17
+ SUPPORTED = [JSON, MESSAGE_PACK, MESSAGE_PACK_ALIAS, MARSHAL].freeze
18
+
19
+ def fetch(name)
20
+ case normalize_name(name)
21
+ when JSON then PayloadSerializers::Json
22
+ when MESSAGE_PACK then PayloadSerializers::MessagePack
23
+ when MARSHAL then PayloadSerializers::Marshal
24
+ else
25
+ raise ConfigurationError, "Unsupported payload serializer: #{name.inspect}"
26
+ end
27
+ end
28
+
29
+ def normalize_name(name)
30
+ normalized = name.to_sym
31
+ normalized == MESSAGE_PACK_ALIAS ? MESSAGE_PACK : normalized
32
+ rescue NoMethodError
33
+ name
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "logger"
6
+
7
+ module ActiveJob
8
+ module Temporal
9
+ module PayloadStorage
10
+ extend self
11
+
12
+ VERSION = 1
13
+ REFERENCE_KEY = :external_payload_reference
14
+
15
+ def external?(payload)
16
+ payload[:external_payload] == true || payload["external_payload"] == true
17
+ end
18
+
19
+ def offload_if_needed(payload, config:, metadata:, workflow_control_fields:)
20
+ return payload unless configured?(config)
21
+ return payload unless payload_exceeds_threshold?(payload, config)
22
+
23
+ reference = dump_payload(payload, config, metadata)
24
+ envelope = {
25
+ external_payload: true,
26
+ external_payload_version: VERSION,
27
+ REFERENCE_KEY => reference
28
+ }
29
+ preserve_workflow_control_fields(payload, envelope, workflow_control_fields)
30
+ end
31
+
32
+ def load(payload, config:, workflow_control_fields:)
33
+ return payload unless external?(payload)
34
+
35
+ version = payload[:external_payload_version] || payload["external_payload_version"]
36
+ unless version == VERSION
37
+ raise ActiveJob::SerializationError, "Unsupported external payload version: #{version.inspect}"
38
+ end
39
+
40
+ loaded_payload = load_payload(payload, config)
41
+ preserve_workflow_control_fields(payload, loaded_payload, workflow_control_fields)
42
+ end
43
+
44
+ def delete(payload, config:)
45
+ return unless external?(payload)
46
+
47
+ adapter = storage_adapter(config)
48
+ return unless adapter.respond_to?(:delete)
49
+
50
+ adapter.delete(payload_reference(payload))
51
+ rescue StandardError => e
52
+ ActiveJob::Temporal::Logger.warn(
53
+ "payload_storage_delete_failed",
54
+ error_class: e.class.name,
55
+ error_message: e.message
56
+ )
57
+ end
58
+
59
+ private
60
+
61
+ def configured?(config)
62
+ !config.payload_storage_adapter.nil? && !config.payload_storage_threshold_kb.nil?
63
+ end
64
+
65
+ def payload_exceeds_threshold?(payload, config)
66
+ JSON.generate(payload).bytesize > (config.payload_storage_threshold_kb * 1024)
67
+ end
68
+
69
+ def dump_payload(payload, config, metadata)
70
+ storage_adapter(config).dump(payload, metadata: metadata.compact)
71
+ rescue ActiveJob::SerializationError
72
+ raise
73
+ rescue StandardError => e
74
+ raise ActiveJob::SerializationError, "Unable to store external payload: #{e.message}"
75
+ end
76
+
77
+ def load_payload(payload, config)
78
+ loaded_payload = storage_adapter(config).load(payload_reference(payload))
79
+ return loaded_payload if loaded_payload.is_a?(Hash)
80
+
81
+ raise ActiveJob::SerializationError, "External payload adapter returned #{loaded_payload.class}, expected Hash"
82
+ end
83
+
84
+ def storage_adapter(config)
85
+ config.payload_storage_adapter ||
86
+ raise(ActiveJob::SerializationError, "External payload requires payload_storage_adapter")
87
+ end
88
+
89
+ def payload_reference(payload)
90
+ payload[REFERENCE_KEY] || payload[REFERENCE_KEY.to_s]
91
+ end
92
+
93
+ def preserve_workflow_control_fields(source_payload, target_payload, workflow_control_fields)
94
+ workflow_control_fields.each do |key|
95
+ value = source_payload[key] || source_payload[key.to_s]
96
+ target_payload[key] = value if value
97
+ end
98
+
99
+ target_payload
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveJob
4
+ module Temporal
5
+ module RailsEnvironmentLoader
6
+ class Error < StandardError; end
7
+
8
+ Result = Struct.new(:loaded, :rails_root, :environment_path, :warnings, keyword_init: true) do
9
+ def loaded?
10
+ loaded
11
+ end
12
+ end
13
+
14
+ UNSAFE_WRITE_BITS = 0o022
15
+
16
+ module_function
17
+
18
+ def load!(rails_root:, warning_io: $stderr, require_environment: Kernel.method(:require))
19
+ result = resolve(rails_root)
20
+ result.warnings.each { |warning| warning_io.puts(warning) }
21
+ return result unless result.loaded?
22
+
23
+ require_environment.call(result.environment_path)
24
+ eager_load_rails_application
25
+ result
26
+ end
27
+
28
+ def resolve(rails_root)
29
+ explicit_root = rails_root != "."
30
+ expanded_root = File.expand_path(rails_root)
31
+ return missing_root_result(expanded_root, explicit_root) unless File.directory?(expanded_root)
32
+
33
+ resolve_existing_root(rails_root, explicit_root, File.realpath(expanded_root))
34
+ rescue Errno::EACCES => e
35
+ raise Error, "Cannot inspect Rails application at: #{expanded_root} (#{e.message})"
36
+ end
37
+
38
+ def resolve_existing_root(rails_root, explicit_root, canonical_root)
39
+ paths = rails_paths(canonical_root)
40
+ return non_rails_result(canonical_root, rails_root, explicit_root) unless File.file?(paths.fetch(:application))
41
+
42
+ environment_path = paths.fetch(:environment)
43
+ raise Error, "Cannot find Rails environment at: #{environment_path}" unless File.file?(environment_path)
44
+
45
+ canonical_paths = canonicalize_paths(
46
+ canonical_root,
47
+ paths.fetch(:config),
48
+ paths.fetch(:application),
49
+ environment_path
50
+ )
51
+ validate_paths_stay_under_root!(canonical_root, canonical_paths)
52
+ validate_paths_not_writable_by_group_or_world!(canonical_paths)
53
+
54
+ Result.new(
55
+ loaded: true,
56
+ rails_root: canonical_root,
57
+ environment_path: canonical_paths.fetch(environment_path),
58
+ warnings: owner_warnings(canonical_paths.values)
59
+ )
60
+ end
61
+
62
+ def rails_paths(canonical_root)
63
+ config_path = File.join(canonical_root, "config")
64
+ {
65
+ config: config_path,
66
+ application: File.join(config_path, "application.rb"),
67
+ environment: File.join(config_path, "environment.rb")
68
+ }
69
+ end
70
+
71
+ def missing_root_result(expanded_root, explicit_root)
72
+ raise Error, "Cannot find Rails application at: #{expanded_root}" if explicit_root
73
+
74
+ Result.new(loaded: false, rails_root: expanded_root, environment_path: nil, warnings: [])
75
+ end
76
+
77
+ def non_rails_result(canonical_root, rails_root, explicit_root)
78
+ warnings = explicit_root ? non_rails_warnings(rails_root) : []
79
+ Result.new(loaded: false, rails_root: canonical_root, environment_path: nil, warnings: warnings)
80
+ end
81
+
82
+ def canonicalize_paths(*paths)
83
+ paths.to_h { |path| [path, File.realpath(path)] }
84
+ end
85
+
86
+ def validate_paths_stay_under_root!(canonical_root, canonical_paths)
87
+ escaped_path = canonical_paths.values.find { |path| !path_under_root?(canonical_root, path) }
88
+ return unless escaped_path
89
+
90
+ raise Error, "Refusing to load Rails environment path outside RAILS_ROOT: #{escaped_path}"
91
+ end
92
+
93
+ def validate_paths_not_writable_by_group_or_world!(paths)
94
+ unsafe_path = paths.values.find { |path| group_or_world_writable?(path) }
95
+ return unless unsafe_path
96
+
97
+ raise Error, "refusing to load Rails environment from group- or world-writable path: #{unsafe_path}"
98
+ end
99
+
100
+ def path_under_root?(canonical_root, path)
101
+ path == canonical_root || path.start_with?("#{canonical_root}#{File::SEPARATOR}")
102
+ end
103
+
104
+ def group_or_world_writable?(path)
105
+ File.stat(path).mode.anybits?(UNSAFE_WRITE_BITS)
106
+ end
107
+
108
+ def owner_warnings(paths)
109
+ current_uid = Process.uid
110
+ unsafe_owner_path = paths.find do |path|
111
+ owner_uid = File.stat(path).uid
112
+ owner_uid != current_uid && !owner_uid.zero?
113
+ end
114
+ return [] unless unsafe_owner_path
115
+
116
+ [
117
+ "Warning: Rails environment path is not owned by the current user or root: #{unsafe_owner_path}"
118
+ ]
119
+ end
120
+
121
+ def non_rails_warnings(rails_root)
122
+ [
123
+ "Warning: #{rails_root} does not appear to be a Rails application",
124
+ "Continuing without Rails environment. Job classes may not be available."
125
+ ]
126
+ end
127
+
128
+ def eager_load_rails_application
129
+ return unless Object.const_defined?(:Rails)
130
+
131
+ rails = Object.const_get(:Rails)
132
+ return unless rails.respond_to?(:application) && rails.respond_to?(:env)
133
+
134
+ environment = rails.env
135
+ return unless environment.respond_to?(:development?) && environment.respond_to?(:test?)
136
+ return unless environment.development? || environment.test?
137
+
138
+ application = rails.application
139
+ application.eager_load! if application.respond_to?(:eager_load!)
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job"
4
+ require "active_support/concern"
5
+ require "active_support/core_ext/numeric/time"
6
+
7
+ module ActiveJob
8
+ module Temporal
9
+ # Provides per-job rate limit metadata via the +rate_limit+ class method.
10
+ module RateLimitOptions
11
+ extend ActiveSupport::Concern
12
+
13
+ PERIOD_SECONDS = {
14
+ second: 1.0,
15
+ minute: 60.0,
16
+ hour: 3600.0
17
+ }.freeze
18
+
19
+ def self.normalize(limit, per:, key: nil)
20
+ normalized_limit = normalize_limit(limit)
21
+ normalized_interval = normalize_interval(per)
22
+ normalized = { limit: normalized_limit, interval: normalized_interval }
23
+ normalized[:key] = normalize_key(key) if key
24
+ normalized
25
+ end
26
+
27
+ def self.normalize_hash(value)
28
+ raise ArgumentError, "rate limit must be a Hash" unless value.is_a?(Hash)
29
+
30
+ limit = value[:limit] || value["limit"]
31
+ interval = value[:interval] || value["interval"]
32
+ per = value[:per] || value["per"] || interval
33
+ key = value[:key] || value["key"]
34
+
35
+ normalize(limit, per: per, key: key)
36
+ end
37
+
38
+ def self.normalize_limit(limit)
39
+ raise ArgumentError, "rate limit must be a positive integer" unless limit.is_a?(Integer) && limit.positive?
40
+
41
+ limit
42
+ end
43
+ private_class_method :normalize_limit
44
+
45
+ def self.normalize_interval(value)
46
+ interval = case value
47
+ when Symbol
48
+ PERIOD_SECONDS.fetch(value) { raise ArgumentError, "unsupported rate limit period: #{value}" }
49
+ when ActiveSupport::Duration, Numeric
50
+ value.to_f
51
+ else
52
+ raise ArgumentError, "rate limit period must be a Symbol, Numeric, or ActiveSupport::Duration"
53
+ end
54
+
55
+ unless interval.finite? && interval.positive?
56
+ raise ArgumentError, "rate limit period must be finite and positive"
57
+ end
58
+
59
+ interval
60
+ end
61
+ private_class_method :normalize_interval
62
+
63
+ def self.normalize_key(key)
64
+ normalized_key = key.to_s.strip
65
+ raise ArgumentError, "rate limit key must be present" if normalized_key.empty?
66
+
67
+ normalized_key
68
+ end
69
+ private_class_method :normalize_key
70
+
71
+ class_methods do
72
+ def rate_limit(limit = nil, per: nil, key: nil)
73
+ if limit
74
+ raise ArgumentError, "rate limit period is required" if per.nil?
75
+
76
+ @rate_limit_options = RateLimitOptions.normalize(limit, per: per, key: key)
77
+ end
78
+
79
+ @rate_limit_options || inherited_rate_limit_options || {}
80
+ end
81
+
82
+ private
83
+
84
+ def inherited_rate_limit_options
85
+ return unless superclass.respond_to?(:rate_limit)
86
+
87
+ superclass.rate_limit
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
93
+
94
+ ActiveJob::Base.include(ActiveJob::Temporal::RateLimitOptions) if defined?(ActiveJob::Base)
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent/map"
4
+
5
+ require_relative "../rate_limit_options"
6
+
7
+ module ActiveJob
8
+ module Temporal
9
+ module RateLimiters
10
+ class MemoryBucketStore
11
+ Bucket = Struct.new(:timestamps, :mutex, :last_touched_at, :references)
12
+ SWEEP_INTERVAL = 1.0
13
+
14
+ def initialize
15
+ @buckets_by_key = Concurrent::Map.new
16
+ @buckets_mutex = Mutex.new
17
+ @next_sweep_at = nil
18
+ end
19
+
20
+ def acquire(keys, now)
21
+ @buckets_mutex.synchronize do
22
+ keys.uniq.sort.map do |key|
23
+ bucket = bucket_for(key, now)
24
+ bucket.references += 1
25
+ [key, bucket]
26
+ end
27
+ end
28
+ end
29
+
30
+ def release(bucket_entries, now)
31
+ @buckets_mutex.synchronize do
32
+ bucket_entries.each do |key, bucket|
33
+ bucket.references -= 1
34
+ @buckets_by_key.delete_pair(key, bucket) if evictable_bucket?(key, bucket, now)
35
+ end
36
+ end
37
+ end
38
+
39
+ def synchronize(bucket_entries, &)
40
+ synchronize_buckets(bucket_entries.map(&:last), &)
41
+ end
42
+
43
+ def touch(bucket_entries, now)
44
+ bucket_entries.each do |entry|
45
+ bucket = entry.fetch(1)
46
+ bucket.last_touched_at = now unless bucket.timestamps.empty?
47
+ end
48
+ end
49
+
50
+ def prune_expired_timestamps(bucket, interval, now)
51
+ cutoff = now - interval
52
+ timestamps = bucket.timestamps
53
+ timestamps.shift while timestamps.first && timestamps.first <= cutoff
54
+ timestamps
55
+ end
56
+
57
+ def sweep_if_due(now)
58
+ return unless sweep_due?(now)
59
+
60
+ bucket_entries = acquire_sweep_bucket_entries
61
+ synchronize(bucket_entries) do
62
+ bucket_entries.each do |key, bucket|
63
+ prune_expired_timestamps(bucket, key.fetch(1), now)
64
+ end
65
+ end
66
+ ensure
67
+ release(bucket_entries, now) if bucket_entries
68
+ end
69
+
70
+ private
71
+
72
+ def bucket_for(key, now)
73
+ @buckets_by_key.compute_if_absent(key) { Bucket.new([], Mutex.new, now, 0) }
74
+ end
75
+
76
+ def sweep_due?(now)
77
+ @buckets_mutex.synchronize do
78
+ return false if @next_sweep_at && now < @next_sweep_at
79
+
80
+ @next_sweep_at = now + SWEEP_INTERVAL
81
+ true
82
+ end
83
+ end
84
+
85
+ def acquire_sweep_bucket_entries
86
+ @buckets_mutex.synchronize do
87
+ @buckets_by_key.each_pair
88
+ .to_a
89
+ .sort_by(&:first)
90
+ .each_with_object([]) do |(key, bucket), bucket_entries|
91
+ next unless bucket.references.zero?
92
+
93
+ bucket.references += 1
94
+ bucket_entries << [key, bucket]
95
+ end
96
+ end
97
+ end
98
+
99
+ def evictable_bucket?(key, bucket, now)
100
+ bucket.references.zero? &&
101
+ bucket.timestamps.empty? &&
102
+ now - bucket.last_touched_at >= key.fetch(1)
103
+ end
104
+
105
+ def synchronize_buckets(buckets, index = 0, &)
106
+ return yield if index == buckets.length
107
+
108
+ buckets[index].mutex.synchronize do
109
+ synchronize_buckets(buckets, index + 1, &)
110
+ end
111
+ end
112
+ end
113
+
114
+ # Process-local sliding-window limiter for development, tests, and single-worker setups.
115
+ class Memory
116
+ def initialize(clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
117
+ @clock = clock
118
+ @bucket_store = MemoryBucketStore.new
119
+ end
120
+
121
+ def wait_time_for(rate_limits)
122
+ limits = normalize_rate_limits(rate_limits)
123
+ return 0.0 if limits.empty?
124
+
125
+ now = @clock.call.to_f
126
+ with_bucket_entries(limits, now) do |bucket_entries|
127
+ wait_time_for_limits(limits, now, bucket_entries)
128
+ end
129
+ end
130
+
131
+ private
132
+
133
+ def normalize_rate_limits(rate_limits)
134
+ Array(rate_limits).map { |rate_limit| normalize_rate_limit(rate_limit) }
135
+ end
136
+
137
+ def normalize_rate_limit(rate_limit)
138
+ normalized = RateLimitOptions.normalize_hash(rate_limit)
139
+ key = normalized[:key].to_s.strip
140
+ raise ArgumentError, "rate limit key must be present" if key.empty?
141
+
142
+ normalized.merge(key: key)
143
+ end
144
+
145
+ def wait_time_for_limit(rate_limit, now, bucket)
146
+ timestamps = active_timestamps(rate_limit, now, bucket)
147
+ return 0.0 if timestamps.length < rate_limit[:limit]
148
+
149
+ [timestamps.first + rate_limit[:interval] - now, 0.0].max
150
+ end
151
+
152
+ def record(rate_limit, now, bucket)
153
+ active_timestamps(rate_limit, now, bucket) << now
154
+ end
155
+
156
+ def record_limits(limits, now, bucket_entries_by_key)
157
+ limits.uniq { |rate_limit| bucket_key(rate_limit) }.each do |rate_limit|
158
+ record(rate_limit, now, bucket_entries_by_key.fetch(bucket_key(rate_limit)))
159
+ end
160
+ end
161
+
162
+ def active_timestamps(rate_limit, now, bucket)
163
+ @bucket_store.prune_expired_timestamps(bucket, rate_limit[:interval], now)
164
+ end
165
+
166
+ def bucket_key(rate_limit)
167
+ [rate_limit[:key], rate_limit[:interval]].freeze
168
+ end
169
+
170
+ def bucket_keys_for(limits)
171
+ limits.map { |rate_limit| bucket_key(rate_limit) }
172
+ end
173
+
174
+ def with_bucket_entries(limits, now)
175
+ bucket_entries = @bucket_store.acquire(bucket_keys_for(limits), now)
176
+ @bucket_store.synchronize(bucket_entries) do
177
+ yield bucket_entries
178
+ end
179
+ ensure
180
+ if bucket_entries
181
+ @bucket_store.release(bucket_entries, now)
182
+ @bucket_store.sweep_if_due(now)
183
+ end
184
+ end
185
+
186
+ def wait_time_for_limits(limits, now, bucket_entries)
187
+ bucket_entries_by_key = bucket_entries.to_h
188
+ wait_time = limits.map do |rate_limit|
189
+ wait_time_for_limit(rate_limit, now, bucket_entries_by_key.fetch(bucket_key(rate_limit)))
190
+ end.max || 0.0
191
+ record_limits(limits, now, bucket_entries_by_key) unless wait_time.positive?
192
+ @bucket_store.touch(bucket_entries, now)
193
+ wait_time
194
+ end
195
+ end
196
+ end
197
+ end
198
+ end