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