activestorage-multi_node_disk 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 +43 -0
- data/CONTRIBUTING.md +89 -0
- data/LICENSE +21 -0
- data/README.md +249 -0
- data/SECURITY.md +33 -0
- data/app/controllers/active_storage/multi_node_disk/blobs_controller.rb +166 -0
- data/config/routes.rb +10 -0
- data/lib/active_storage/multi_node_disk/audit.rb +15 -0
- data/lib/active_storage/multi_node_disk/audit_log.rb +48 -0
- data/lib/active_storage/multi_node_disk/configuration.rb +217 -0
- data/lib/active_storage/multi_node_disk/configuration_error.rb +8 -0
- data/lib/active_storage/multi_node_disk/delete_job.rb +27 -0
- data/lib/active_storage/multi_node_disk/engine.rb +24 -0
- data/lib/active_storage/multi_node_disk/fan_out.rb +76 -0
- data/lib/active_storage/multi_node_disk/file_error.rb +36 -0
- data/lib/active_storage/multi_node_disk/file_sink.rb +38 -0
- data/lib/active_storage/multi_node_disk/mismatch.rb +15 -0
- data/lib/active_storage/multi_node_disk/net_http_client.rb +31 -0
- data/lib/active_storage/multi_node_disk/node_addresses.rb +50 -0
- data/lib/active_storage/multi_node_disk/not_here.rb +15 -0
- data/lib/active_storage/multi_node_disk/requester.rb +50 -0
- data/lib/active_storage/multi_node_disk/response.rb +16 -0
- data/lib/active_storage/multi_node_disk/test_helper.rb +97 -0
- data/lib/active_storage/multi_node_disk/unavailable.rb +16 -0
- data/lib/active_storage/multi_node_disk/version.rb +7 -0
- data/lib/active_storage/multi_node_disk.rb +172 -0
- data/lib/active_storage/service/multi_node_disk_service.rb +322 -0
- data/lib/activestorage-multi_node_disk.rb +4 -0
- data/lib/generators/active_storage/multi_node_disk/install_generator.rb +47 -0
- data/lib/generators/active_storage/multi_node_disk/templates/create_audits.rb.tt +33 -0
- data/lib/generators/active_storage/multi_node_disk/templates/initializer.rb.tt +26 -0
- metadata +141 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveStorage
|
|
4
|
+
module MultiNodeDisk
|
|
5
|
+
# One audit entry per request the endpoint answered (served, refused or
|
|
6
|
+
# deleted) and per remote fetch or delete this node made, as one JSON
|
|
7
|
+
# document (schema VERSION) to the configured sink:
|
|
8
|
+
#
|
|
9
|
+
# - :file (default): a JSON line appended to config.audit_path;
|
|
10
|
+
# - :table: an Audit row (after the host ran the generator's migration);
|
|
11
|
+
# - :none: nothing; or any callable, given the entry.
|
|
12
|
+
#
|
|
13
|
+
# The same entry is instrumented as `audit.multi_node_disk`. It never
|
|
14
|
+
# holds a token, file contents or a signed URL. A sink that fails is
|
|
15
|
+
# logged and never fails the request.
|
|
16
|
+
module AuditLog
|
|
17
|
+
VERSION = 1
|
|
18
|
+
|
|
19
|
+
# @param entry [Hash] action, result, status, key and the rest
|
|
20
|
+
# @param request [ActionDispatch::Request, nil] the request answered, if any
|
|
21
|
+
# @param requester [Hash, nil] who the request is for (else the current one)
|
|
22
|
+
# @return [Hash] the entry written
|
|
23
|
+
def self.record(entry, request: nil, requester: MultiNodeDisk.current_requester)
|
|
24
|
+
config = MultiNodeDisk.config
|
|
25
|
+
document = {v: VERSION, at: Time.now.utc.iso8601(3)}.merge(entry)
|
|
26
|
+
document[:requester] = requester || config.audit_requester&.call(request)
|
|
27
|
+
document[:context] = config.audit_context&.call(request, document)
|
|
28
|
+
document = document.compact.as_json
|
|
29
|
+
|
|
30
|
+
MultiNodeDisk.instrument(:audit, document)
|
|
31
|
+
write(config.audit, document)
|
|
32
|
+
document
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.write(sink, document)
|
|
36
|
+
case sink.to_s
|
|
37
|
+
when "none" then nil
|
|
38
|
+
when "file" then FileSink.instance.write(MultiNodeDisk.config.audit_path, document)
|
|
39
|
+
when "table" then Audit.create!(payload: document)
|
|
40
|
+
else sink.call(document)
|
|
41
|
+
end
|
|
42
|
+
rescue => error
|
|
43
|
+
Rails.logger&.error("[ActiveStorage::MultiNodeDisk] audit not written (#{error.class}: #{error.message})")
|
|
44
|
+
end
|
|
45
|
+
private_class_method :write
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ipaddr"
|
|
4
|
+
|
|
5
|
+
module ActiveStorage
|
|
6
|
+
module MultiNodeDisk
|
|
7
|
+
# The hooks a host may change. Each default is what a plain Rails 8.1 app
|
|
8
|
+
# would do; a host sets only what differs, in one initializer:
|
|
9
|
+
#
|
|
10
|
+
# ActiveStorage::MultiNodeDisk.configure do |config|
|
|
11
|
+
# config.url_for = ->(node, key) { "https://#{node}:8443#{ActiveStorage::MultiNodeDisk.mount_path}/blobs/#{key}" }
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# The engine validates and freezes it when the app has booted; threads
|
|
15
|
+
# then share it read-only.
|
|
16
|
+
class Configuration
|
|
17
|
+
# Hooks that must be callables (the security hooks among them: a
|
|
18
|
+
# static `true` must never authenticate).
|
|
19
|
+
CALLABLES = %i[url_for authenticate error_mapper].freeze
|
|
20
|
+
# Hooks that may be a value or a callable.
|
|
21
|
+
VALUES = %i[current_node nodes token token_for].freeze
|
|
22
|
+
TIMEOUTS = %i[open_timeout read_timeout].freeze
|
|
23
|
+
OPTIONS = (CALLABLES + VALUES + TIMEOUTS + %i[auth allowed_ips http_client hop_header requester_header draw_routes delete_mode delete_job_queue delete_job_class audit audit_path audit_context audit_requester table_name_prefix]).freeze
|
|
24
|
+
AUTH_MODES = %i[none token ip].freeze
|
|
25
|
+
DELETE_MODES = %i[sync job].freeze
|
|
26
|
+
AUDIT_SINKS = %i[none file table].freeze
|
|
27
|
+
# Longest table_name_prefix: "<prefix>audits_seq" must fit in 30.
|
|
28
|
+
MAX_PREFIX = 30 - "audits_seq".length
|
|
29
|
+
HEADER_NAME = /\A[A-Za-z0-9-]+\z/
|
|
30
|
+
|
|
31
|
+
# @return [String, #call] this node: `-> String` (storage.yml `node:` wins)
|
|
32
|
+
attr_accessor :current_node
|
|
33
|
+
# @return [Array<String>, #call] every node: `-> Array` (storage.yml `nodes:` wins)
|
|
34
|
+
attr_accessor :nodes
|
|
35
|
+
# @return [#call] a node's URL for a file: `(node, key) -> String`, https only
|
|
36
|
+
attr_accessor :url_for
|
|
37
|
+
# @return [Symbol, Array<Symbol>] the endpoint's checks: :none (the
|
|
38
|
+
# default: no check at all), :token, :ip, or [:ip, :token] (both)
|
|
39
|
+
attr_accessor :auth
|
|
40
|
+
# @return [Array<String>, nil] IPs and CIDRs for :ip (nil: every node's
|
|
41
|
+
# addresses, resolved by DNS and refreshed every NodeAddresses::TTL)
|
|
42
|
+
attr_accessor :allowed_ips
|
|
43
|
+
# @return [String, #call] the shared token for :token: `-> String`
|
|
44
|
+
attr_accessor :token
|
|
45
|
+
# @return [String, #call] the token sent to a node: `(node, blob, action) -> String`
|
|
46
|
+
attr_accessor :token_for
|
|
47
|
+
# @return [#call] whether a token is a node's, for :token:
|
|
48
|
+
# `(token, request) -> identity or nil` (nil refuses)
|
|
49
|
+
attr_accessor :authenticate
|
|
50
|
+
# @return [#call] `#call(method, url, headers:, node:, open_timeout:, read_timeout:) -> Response`;
|
|
51
|
+
# called from several threads at once, so it must not share a connection
|
|
52
|
+
attr_accessor :http_client
|
|
53
|
+
# @return [String] the header that marks a node-to-node request
|
|
54
|
+
attr_accessor :hop_header
|
|
55
|
+
# @return [Numeric] seconds
|
|
56
|
+
attr_accessor :open_timeout, :read_timeout
|
|
57
|
+
# @return [#call] `(error) -> Exception`, raised in place of the gem's error
|
|
58
|
+
attr_accessor :error_mapper
|
|
59
|
+
# @return [Boolean] whether the gem mounts its endpoint at mount_path
|
|
60
|
+
attr_accessor :draw_routes
|
|
61
|
+
# @return [String] the header that carries the requester to another node
|
|
62
|
+
attr_accessor :requester_header
|
|
63
|
+
# @return [Symbol, #call] where audit entries go: :file (default),
|
|
64
|
+
# :table, :none, or a callable given each entry
|
|
65
|
+
attr_accessor :audit
|
|
66
|
+
# @return [String, Pathname] the :file sink's file
|
|
67
|
+
attr_accessor :audit_path
|
|
68
|
+
# @return [#call, nil] more for each entry: `(request or nil, entry) -> Hash`
|
|
69
|
+
# (the entry so far: action, key and the rest)
|
|
70
|
+
attr_accessor :audit_context
|
|
71
|
+
# @return [#call, nil] who it is for: `(request or nil) -> Hash`
|
|
72
|
+
# (with_requester wins; the gem never reads it)
|
|
73
|
+
attr_accessor :audit_requester
|
|
74
|
+
# @return [String] the prefix of the gem's tables (the audit table)
|
|
75
|
+
attr_accessor :table_name_prefix
|
|
76
|
+
# @return [Symbol] how delete reaches the other nodes: :sync (inline,
|
|
77
|
+
# raising Unavailable when one does not confirm) or :job (DeleteJob)
|
|
78
|
+
attr_accessor :delete_mode
|
|
79
|
+
# @return [String, Symbol, nil] DeleteJob's queue (nil: Active Storage's purge queue)
|
|
80
|
+
attr_accessor :delete_job_queue
|
|
81
|
+
# @return [String, Class, nil] a DeleteJob subclass (other retries)
|
|
82
|
+
attr_accessor :delete_job_class
|
|
83
|
+
|
|
84
|
+
def initialize
|
|
85
|
+
@current_node = -> { Socket.gethostname }
|
|
86
|
+
@nodes = nil
|
|
87
|
+
@url_for = ->(node, key) { "https://#{node}#{MultiNodeDisk.mount_path}/blobs/#{key}" }
|
|
88
|
+
@auth = :none
|
|
89
|
+
@allowed_ips = nil
|
|
90
|
+
@token = -> { MultiNodeDisk.credentials_token }
|
|
91
|
+
@token_for = ->(_node, _blob, _action) { MultiNodeDisk.config.resolve(:token) }
|
|
92
|
+
@authenticate = ->(token, _request) { :node if Configuration.token?(token) }
|
|
93
|
+
@http_client = NetHttpClient.new
|
|
94
|
+
@hop_header = "X-Multi-Node-Disk-Hop"
|
|
95
|
+
@open_timeout = 3
|
|
96
|
+
@read_timeout = 20
|
|
97
|
+
@error_mapper = ->(error) { error }
|
|
98
|
+
@draw_routes = true
|
|
99
|
+
@requester_header = "X-Multi-Node-Disk-Requester"
|
|
100
|
+
@audit = :file
|
|
101
|
+
@audit_path = Rails.root.join("tmp/multi_node_disk/audit.log")
|
|
102
|
+
@audit_context = nil
|
|
103
|
+
@audit_requester = nil
|
|
104
|
+
@table_name_prefix = "multi_node_disk_"
|
|
105
|
+
@delete_mode = :sync
|
|
106
|
+
@delete_job_queue = nil
|
|
107
|
+
@delete_job_class = nil
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# The default authenticate: config.token (by default the credentials
|
|
111
|
+
# token), compared in constant time. No token configured: nobody.
|
|
112
|
+
#
|
|
113
|
+
# @param token [String, nil]
|
|
114
|
+
# @return [Boolean]
|
|
115
|
+
def self.token?(token)
|
|
116
|
+
expected = MultiNodeDisk.config.resolve(:token).to_s
|
|
117
|
+
expected.present? && token.present? && ActiveSupport::SecurityUtils.secure_compare(token, expected)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# @param auth [Symbol, String, Array]
|
|
121
|
+
# @return [Array<Symbol>] the modes
|
|
122
|
+
# @raise [ConfigurationError] for an unknown mode, none, or :none combined
|
|
123
|
+
def self.auth_modes(auth)
|
|
124
|
+
modes = Array(auth).map { |mode| mode.to_s.to_sym }.uniq
|
|
125
|
+
valid = modes.any? && (modes - AUTH_MODES).empty? && (modes == [:none] || !modes.include?(:none))
|
|
126
|
+
raise ConfigurationError, "ActiveStorage::MultiNodeDisk auth must be :none, :token, :ip or [:ip, :token] (got #{auth.inspect})" unless valid
|
|
127
|
+
|
|
128
|
+
modes
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# @param list [Array<String>, nil]
|
|
132
|
+
# @return [Array<IPAddr>, nil]
|
|
133
|
+
# @raise [ConfigurationError] for an entry that is not an IP or CIDR
|
|
134
|
+
def self.ip_list(list)
|
|
135
|
+
return if list.nil?
|
|
136
|
+
|
|
137
|
+
Array(list).map { |entry| IPAddr.new(entry.to_s.strip) }
|
|
138
|
+
rescue IPAddr::Error
|
|
139
|
+
raise ConfigurationError, "ActiveStorage::MultiNodeDisk allowed_ips: #{list.inspect} holds an entry that is not an IP or CIDR"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# @param value [Object]
|
|
143
|
+
# @return [Boolean] a positive, finite number
|
|
144
|
+
def self.seconds?(value)
|
|
145
|
+
value.is_a?(Numeric) && value.positive? && value.finite?
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# The option's value, called with the arguments when it is callable.
|
|
149
|
+
#
|
|
150
|
+
# @param option [Symbol]
|
|
151
|
+
# @return [Object]
|
|
152
|
+
def resolve(option, *args)
|
|
153
|
+
value = public_send(option)
|
|
154
|
+
value.respond_to?(:call) ? value.call(*args) : value
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# @return [self]
|
|
158
|
+
# @raise [ConfigurationError] naming the first option that is wrong
|
|
159
|
+
def validate!
|
|
160
|
+
CALLABLES.each { |option| fail_option(option, "must respond to call") unless public_send(option).respond_to?(:call) }
|
|
161
|
+
%i[current_node token token_for].each { |option| string_or_callable!(option) }
|
|
162
|
+
Configuration.auth_modes(auth)
|
|
163
|
+
fail_option(:delete_mode, "must be :sync or :job") unless DELETE_MODES.include?(delete_mode.to_s.to_sym)
|
|
164
|
+
delete_job if delete_mode.to_s == "job"
|
|
165
|
+
Configuration.ip_list(allowed_ips)
|
|
166
|
+
fail_option(:nodes, "must be an Array or respond to call") unless nodes.nil? || nodes.is_a?(Array) || nodes.respond_to?(:call)
|
|
167
|
+
fail_option(:http_client, "must respond to call") unless http_client.respond_to?(:call)
|
|
168
|
+
%i[hop_header requester_header].each { |option| fail_option(option, "must be a header name") unless public_send(option).is_a?(String) && HEADER_NAME.match?(public_send(option)) }
|
|
169
|
+
%i[audit_context audit_requester].each { |option| fail_option(option, "must be nil or respond to call") unless public_send(option).nil? || public_send(option).respond_to?(:call) }
|
|
170
|
+
fail_option(:audit, "must be :file, :table, :none or respond to call") unless audit.respond_to?(:call) || AUDIT_SINKS.include?(audit.to_s.to_sym)
|
|
171
|
+
fail_option(:table_name_prefix, "must be a lowercase identifier prefix") unless table_name_prefix.is_a?(String) && table_name_prefix.match?(/\A[a-z][a-z0-9_]*\z/)
|
|
172
|
+
# "<prefix>audits_seq" (the sequence Oracle's adapter adds) within 30.
|
|
173
|
+
fail_option(:table_name_prefix, "must be at most #{MAX_PREFIX} characters (Oracle's 30-character names)") if table_name_prefix.length > MAX_PREFIX
|
|
174
|
+
TIMEOUTS.each { |option| fail_option(option, "must be a positive, finite number of seconds") unless Configuration.seconds?(public_send(option)) }
|
|
175
|
+
self
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# The job for :job mode, loading Active Job and the gem's job.
|
|
179
|
+
#
|
|
180
|
+
# @return [Class]
|
|
181
|
+
# @raise [ConfigurationError] when Active Job is not available
|
|
182
|
+
def delete_job
|
|
183
|
+
begin
|
|
184
|
+
require "active_job"
|
|
185
|
+
require "active_storage/multi_node_disk/delete_job"
|
|
186
|
+
rescue LoadError => error
|
|
187
|
+
raise ConfigurationError, "ActiveStorage::MultiNodeDisk delete_mode :job needs Active Job (#{error.message})"
|
|
188
|
+
end
|
|
189
|
+
job = delete_job_class.is_a?(String) ? delete_job_class.constantize : (delete_job_class || DeleteJob)
|
|
190
|
+
fail_option(:delete_job_class, "must be ActiveStorage::MultiNodeDisk::DeleteJob or a subclass") unless job.is_a?(Class) && job <= DeleteJob
|
|
191
|
+
job
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# A validated, frozen copy with some options replaced.
|
|
195
|
+
#
|
|
196
|
+
# @param overrides [Hash{Symbol => Object}]
|
|
197
|
+
# @return [Configuration]
|
|
198
|
+
def merge(**overrides)
|
|
199
|
+
unknown = overrides.keys - OPTIONS
|
|
200
|
+
raise ConfigurationError, "unknown option #{unknown.first}" if unknown.any?
|
|
201
|
+
|
|
202
|
+
dup.tap { |copy| overrides.each { |option, value| copy.public_send(:"#{option}=", value) } }.validate!.freeze
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
private
|
|
206
|
+
|
|
207
|
+
def string_or_callable!(option)
|
|
208
|
+
value = public_send(option)
|
|
209
|
+
fail_option(option, "must be a String or respond to call") unless value.is_a?(String) || value.respond_to?(:call)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def fail_option(option, problem)
|
|
213
|
+
raise ConfigurationError, "ActiveStorage::MultiNodeDisk config.#{option} #{problem}"
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveStorage
|
|
4
|
+
module MultiNodeDisk
|
|
5
|
+
# The other nodes' deletes, when config.delete_mode is :job (the local
|
|
6
|
+
# copy is already gone). Idempotent: a node that answers 404 as itself
|
|
7
|
+
# has nothing left. Retried while a node does not confirm; a host
|
|
8
|
+
# changes the retries by subclassing (config.delete_job_class) and the
|
|
9
|
+
# queue with config.delete_job_queue. Loaded only in :job mode, so
|
|
10
|
+
# Active Job stays optional.
|
|
11
|
+
class DeleteJob < ActiveJob::Base
|
|
12
|
+
retry_on Unavailable, wait: :polynomially_longer, attempts: 10 do |job, error|
|
|
13
|
+
MultiNodeDisk.instrument(:delete_abandoned, key: job.arguments.second, nodes: error.nodes)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# @param service_name [String] the MultiNodeDisk service in storage.yml
|
|
17
|
+
# @param key [String]
|
|
18
|
+
# @param nodes [Array<String>] the nodes still to confirm
|
|
19
|
+
# @param requester [Hash, nil] who the delete was for (recorded again)
|
|
20
|
+
def perform(service_name, key, nodes, requester = nil)
|
|
21
|
+
service = ActiveStorage::Blob.services.fetch(service_name)
|
|
22
|
+
failed = MultiNodeDisk.with_requester(requester) { service.delete_elsewhere(key, nodes: nodes) }
|
|
23
|
+
raise Unavailable.new(nodes: failed, key: key, reason: "delete not confirmed") if failed.any?
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveStorage
|
|
4
|
+
module MultiNodeDisk
|
|
5
|
+
# The endpoint other nodes call, isolated from the app: its controller,
|
|
6
|
+
# its routes and their helpers stay in this namespace. Mounted under
|
|
7
|
+
# Active Storage's own prefix (mount_path, by default
|
|
8
|
+
# /rails/active_storage/multi_node_disk), ahead of the app's routes so a
|
|
9
|
+
# catch-all cannot hide it; nothing is drawn at the app's root.
|
|
10
|
+
class Engine < ::Rails::Engine
|
|
11
|
+
isolate_namespace ActiveStorage::MultiNodeDisk
|
|
12
|
+
|
|
13
|
+
initializer "active_storage_multi_node_disk.routes" do |app|
|
|
14
|
+
app.routes.prepend do
|
|
15
|
+
if ActiveStorage::MultiNodeDisk.config.draw_routes
|
|
16
|
+
mount ActiveStorage::MultiNodeDisk::Engine => ActiveStorage::MultiNodeDisk.mount_path, :as => :active_storage_multi_node_disk
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
config.after_initialize { MultiNodeDisk.finalize! }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveStorage
|
|
4
|
+
module MultiNodeDisk
|
|
5
|
+
# Runs calls in parallel threads (at most MAX_THREADS at a time) under
|
|
6
|
+
# one deadline, and stops once the block says a result wins.
|
|
7
|
+
#
|
|
8
|
+
# 1. Each call runs in its own thread, inside the Rails executor. Calls
|
|
9
|
+
# get everything from the caller: they must not touch the database or
|
|
10
|
+
# host hooks, and a transport must be safe to call from several
|
|
11
|
+
# threads at once.
|
|
12
|
+
# 2. Results arrive as calls finish; the block sees each one, on the
|
|
13
|
+
# caller's thread.
|
|
14
|
+
# 3. The deadline starts here and covers the wait and the block; no call
|
|
15
|
+
# starts after it. When it passes, or the block answers true, calls
|
|
16
|
+
# still running are killed and joined within one shared JOIN_SECONDS
|
|
17
|
+
# budget: their ensure blocks close what they opened; a thread still
|
|
18
|
+
# alive after that is left to finish (it is already killed), and a
|
|
19
|
+
# socket caught mid-connect is closed when it is collected.
|
|
20
|
+
# 4. Calls that never started (more nodes than threads, deadline gone)
|
|
21
|
+
# have no result: the caller counts them as failed.
|
|
22
|
+
module FanOut
|
|
23
|
+
MAX_THREADS = 8
|
|
24
|
+
JOIN_SECONDS = 1
|
|
25
|
+
|
|
26
|
+
# @param calls [Hash{String => #call}] by node
|
|
27
|
+
# @param deadline [Numeric] seconds for the whole run
|
|
28
|
+
# @yieldparam node [String]
|
|
29
|
+
# @yieldparam result [Object, Exception] the call's value, or what it raised
|
|
30
|
+
# @yieldreturn [Boolean] true to stop
|
|
31
|
+
# @return [Hash{String => Object}] the results that arrived, by node
|
|
32
|
+
def self.run(calls, deadline:)
|
|
33
|
+
results = {}
|
|
34
|
+
pending = calls.to_a
|
|
35
|
+
running = {}
|
|
36
|
+
finished = Thread::Queue.new
|
|
37
|
+
stop_at = now + deadline
|
|
38
|
+
|
|
39
|
+
loop do
|
|
40
|
+
while running.size < MAX_THREADS && now < stop_at && (job = pending.shift)
|
|
41
|
+
running[job.first] = start(*job, finished)
|
|
42
|
+
end
|
|
43
|
+
break if running.empty?
|
|
44
|
+
|
|
45
|
+
remaining = stop_at - now
|
|
46
|
+
break unless remaining.positive? && (item = finished.pop(timeout: remaining))
|
|
47
|
+
|
|
48
|
+
node, result = item
|
|
49
|
+
running.delete(node)
|
|
50
|
+
results[node] = result
|
|
51
|
+
break if yield(node, result)
|
|
52
|
+
end
|
|
53
|
+
results
|
|
54
|
+
ensure
|
|
55
|
+
running&.each_value(&:kill)
|
|
56
|
+
cleanup_by = now + JOIN_SECONDS
|
|
57
|
+
running&.each_value { |thread| thread.join([cleanup_by - now, 0].max) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
61
|
+
|
|
62
|
+
def self.start(node, call, finished)
|
|
63
|
+
Thread.new do
|
|
64
|
+
Thread.current.report_on_exception = false
|
|
65
|
+
result = begin
|
|
66
|
+
Rails.application.executor.wrap { call.call }
|
|
67
|
+
rescue => error
|
|
68
|
+
error
|
|
69
|
+
end
|
|
70
|
+
finished << [node, result]
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
private_class_method :now, :start
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveStorage
|
|
4
|
+
module MultiNodeDisk
|
|
5
|
+
# What every file error carries, for triage: the nodes involved, the
|
|
6
|
+
# blob key and the file name.
|
|
7
|
+
module FileError
|
|
8
|
+
# @return [Array<String>]
|
|
9
|
+
attr_reader :nodes
|
|
10
|
+
# @return [String, nil]
|
|
11
|
+
attr_reader :key, :filename
|
|
12
|
+
|
|
13
|
+
# @param nodes [Array<String>, String, nil]
|
|
14
|
+
# @param key [String, nil]
|
|
15
|
+
# @param filename [String, nil]
|
|
16
|
+
# @param reason [String, nil] what the transport or the nodes said
|
|
17
|
+
def initialize(nodes: nil, key: nil, filename: nil, reason: nil)
|
|
18
|
+
@nodes = Array(nodes).map(&:to_s)
|
|
19
|
+
@key = key
|
|
20
|
+
@filename = filename&.to_s
|
|
21
|
+
super([describe, reason].compact.join(": "))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @return [String] the nodes, for messages ("node-b and node-c")
|
|
25
|
+
def node
|
|
26
|
+
nodes.to_sentence.presence || "any node"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def file_label
|
|
32
|
+
filename.presence || key
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module ActiveStorage
|
|
7
|
+
module MultiNodeDisk
|
|
8
|
+
# The default audit sink: one JSON document per line, appended. A lock
|
|
9
|
+
# keeps this process's threads apart and flock keeps processes apart, so
|
|
10
|
+
# lines never interleave. Rotation is the host's (logrotate with
|
|
11
|
+
# copytruncate, or rename the file: the next write opens a new one).
|
|
12
|
+
class FileSink
|
|
13
|
+
# @return [FileSink] the process's sink
|
|
14
|
+
def self.instance
|
|
15
|
+
@instance ||= new
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize
|
|
19
|
+
@lock = Mutex.new
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @param path [String, Pathname]
|
|
23
|
+
# @param document [Hash]
|
|
24
|
+
def write(path, document)
|
|
25
|
+
line = "#{JSON.generate(document)}\n"
|
|
26
|
+
@lock.synchronize do
|
|
27
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
28
|
+
File.open(path, File::WRONLY | File::APPEND | File::CREAT, 0o640) do |file|
|
|
29
|
+
file.flock(File::LOCK_EX)
|
|
30
|
+
file.write(line)
|
|
31
|
+
ensure
|
|
32
|
+
file.flock(File::LOCK_UN)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveStorage
|
|
4
|
+
module MultiNodeDisk
|
|
5
|
+
# The bytes do not match the blob's size and checksum. An
|
|
6
|
+
# IntegrityError, as Active Storage raises.
|
|
7
|
+
class Mismatch < ActiveStorage::IntegrityError
|
|
8
|
+
include FileError
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def describe = "#{file_label}: the bytes do not match the stored file (#{node})"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module ActiveStorage
|
|
7
|
+
module MultiNodeDisk
|
|
8
|
+
# The default transport: Net::HTTP, as Active Storage's own services
|
|
9
|
+
# use, with the system resolver. HTTPS verifies the certificate.
|
|
10
|
+
class NetHttpClient
|
|
11
|
+
METHODS = {get: Net::HTTP::Get, head: Net::HTTP::Head, delete: Net::HTTP::Delete}.freeze
|
|
12
|
+
|
|
13
|
+
# @param method [Symbol] :get, :head or :delete
|
|
14
|
+
# @param url [String]
|
|
15
|
+
# @param headers [Hash{String => String}]
|
|
16
|
+
# @param node [String] the node addressed (unused: the URL names it; a host client may pin to it)
|
|
17
|
+
# @param open_timeout [Numeric]
|
|
18
|
+
# @param read_timeout [Numeric]
|
|
19
|
+
# @return [Response]
|
|
20
|
+
def call(method, url, headers:, node:, open_timeout:, read_timeout:)
|
|
21
|
+
uri = URI(url)
|
|
22
|
+
request = METHODS.fetch(method).new(uri)
|
|
23
|
+
headers.each { |name, value| request[name] = value }
|
|
24
|
+
answer = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: open_timeout, read_timeout: read_timeout) do |http|
|
|
25
|
+
http.request(request)
|
|
26
|
+
end
|
|
27
|
+
Response.new(answer.code.to_i, answer.body.to_s, answer.each_header.to_h)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ipaddr"
|
|
4
|
+
require "resolv"
|
|
5
|
+
|
|
6
|
+
module ActiveStorage
|
|
7
|
+
module MultiNodeDisk
|
|
8
|
+
# The IP addresses of the nodes, for the endpoint's :ip check: resolved
|
|
9
|
+
# by DNS (a node may also be written as an IP), kept for TTL seconds,
|
|
10
|
+
# then resolved again. A node that fails to resolve keeps its last known
|
|
11
|
+
# addresses. One lock guards the cache, so threads share it safely.
|
|
12
|
+
class NodeAddresses
|
|
13
|
+
TTL = 60
|
|
14
|
+
|
|
15
|
+
Entry = Struct.new(:addresses, :expires_at)
|
|
16
|
+
|
|
17
|
+
# @param resolver [#getaddresses] Resolv by default
|
|
18
|
+
# @param clock [#call] monotonic seconds
|
|
19
|
+
def initialize(resolver: Resolv, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
20
|
+
@resolver = resolver
|
|
21
|
+
@clock = clock
|
|
22
|
+
@entries = {}
|
|
23
|
+
@lock = Mutex.new
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @param nodes [Array<String>]
|
|
27
|
+
# @return [Array<IPAddr>]
|
|
28
|
+
def for(nodes)
|
|
29
|
+
@lock.synchronize { nodes.flat_map { |node| entry_for(node).addresses } }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def entry_for(node)
|
|
35
|
+
now = @clock.call
|
|
36
|
+
entry = @entries[node]
|
|
37
|
+
return entry if entry && entry.expires_at > now
|
|
38
|
+
|
|
39
|
+
@entries[node] = Entry.new(resolve(node) || entry&.addresses || [], now + TTL)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def resolve(node)
|
|
43
|
+
found = @resolver.getaddresses(node).map { |address| IPAddr.new(address) }
|
|
44
|
+
found.presence
|
|
45
|
+
rescue Resolv::ResolvError, IPAddr::Error, SocketError
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveStorage
|
|
4
|
+
module MultiNodeDisk
|
|
5
|
+
# No node has the file: this disk does not, and every other node
|
|
6
|
+
# answered "not here" as itself. A FileNotFoundError, as Disk raises.
|
|
7
|
+
class NotHere < ActiveStorage::FileNotFoundError
|
|
8
|
+
include FileError
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def describe = "#{file_label} is not on #{node}"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveStorage
|
|
4
|
+
module MultiNodeDisk
|
|
5
|
+
# Who a file operation is for, set around work that serves no request
|
|
6
|
+
# (a job): `ActiveStorage::MultiNodeDisk.with_requester(...) { blob.open { } }`.
|
|
7
|
+
# Kept per thread or fiber (Rails' isolation level), so concurrent work
|
|
8
|
+
# never shares it. The gem records and forwards it; it never reads it.
|
|
9
|
+
module Requester
|
|
10
|
+
KEY = :active_storage_multi_node_disk_requester
|
|
11
|
+
# Largest requester header sent or read, in bytes.
|
|
12
|
+
MAX_BYTES = 1024
|
|
13
|
+
|
|
14
|
+
# @return [Hash, nil]
|
|
15
|
+
def self.current
|
|
16
|
+
ActiveSupport::IsolatedExecutionState[KEY]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @param requester [Hash, nil]
|
|
20
|
+
def self.with(requester)
|
|
21
|
+
previous = current
|
|
22
|
+
ActiveSupport::IsolatedExecutionState[KEY] = requester
|
|
23
|
+
yield
|
|
24
|
+
ensure
|
|
25
|
+
ActiveSupport::IsolatedExecutionState[KEY] = previous
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @param requester [Hash, nil]
|
|
29
|
+
# @return [String, nil] JSON for the header; nil when absent or too large
|
|
30
|
+
def self.encode(requester)
|
|
31
|
+
return if requester.nil?
|
|
32
|
+
|
|
33
|
+
json = JSON.generate(requester.as_json)
|
|
34
|
+
json if json.bytesize <= MAX_BYTES
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @param header [String, nil]
|
|
38
|
+
# @return [Hash, nil] the requester another node sent; nil when absent,
|
|
39
|
+
# too large or not a JSON object
|
|
40
|
+
def self.decode(header)
|
|
41
|
+
return if header.blank? || header.bytesize > MAX_BYTES
|
|
42
|
+
|
|
43
|
+
found = JSON.parse(header)
|
|
44
|
+
found if found.is_a?(Hash)
|
|
45
|
+
rescue JSON::ParserError
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveStorage
|
|
4
|
+
module MultiNodeDisk
|
|
5
|
+
# What an http_client returns: the status, the body and the headers
|
|
6
|
+
# (names in any case).
|
|
7
|
+
Response = Struct.new(:status, :body, :headers) do
|
|
8
|
+
# @param name [String]
|
|
9
|
+
# @return [String, nil]
|
|
10
|
+
def header(name)
|
|
11
|
+
wanted = name.downcase
|
|
12
|
+
Array((headers || {}).find { |key, _| key.to_s.downcase == wanted }&.last).first
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|