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,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rack/mock"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
|
|
7
|
+
module ActiveStorage
|
|
8
|
+
module MultiNodeDisk
|
|
9
|
+
# Fake nodes for a host's specs: no network, the gem's real endpoint.
|
|
10
|
+
#
|
|
11
|
+
# include ActiveStorage::MultiNodeDisk::TestHelper
|
|
12
|
+
#
|
|
13
|
+
# node_b = multi_node_disk_node("node-b")
|
|
14
|
+
# multi_node_disk_move(blob, to: node_b)
|
|
15
|
+
# multi_node_disk_cluster("node-b" => node_b, "node-c" => :missing) do |requests|
|
|
16
|
+
# expect(blob.download).to eq(bytes)
|
|
17
|
+
# end
|
|
18
|
+
module TestHelper
|
|
19
|
+
# A node's answer when no service stands for it in the cluster.
|
|
20
|
+
BEHAVIOURS = %i[missing timeout down wrong].freeze
|
|
21
|
+
|
|
22
|
+
# Another node: a MultiNodeDiskService with its own disk root, named
|
|
23
|
+
# like the app's service.
|
|
24
|
+
#
|
|
25
|
+
# @param node [String]
|
|
26
|
+
# @param root [String]
|
|
27
|
+
# @return [ActiveStorage::Service::MultiNodeDiskService]
|
|
28
|
+
def multi_node_disk_node(node, root: Dir.mktmpdir("multi_node_disk-#{node}-"))
|
|
29
|
+
here = MultiNodeDisk.service
|
|
30
|
+
ActiveStorage::Service::MultiNodeDiskService.new(root: root, node: node, nodes: here.nodes).tap { |there| there.name = here.name }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Moves a blob's file from this node's disk to another node's.
|
|
34
|
+
#
|
|
35
|
+
# @param blob [ActiveStorage::Blob]
|
|
36
|
+
# @param to [ActiveStorage::Service::MultiNodeDiskService]
|
|
37
|
+
def multi_node_disk_move(blob, to:)
|
|
38
|
+
target = to.path_for(blob.key)
|
|
39
|
+
FileUtils.mkdir_p(File.dirname(target))
|
|
40
|
+
FileUtils.mv(blob.service.path_for(blob.key), target)
|
|
41
|
+
to
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Runs the block with the other nodes answering in process: a service
|
|
45
|
+
# through the gem's endpoint, :missing with a 404 as itself, :wrong
|
|
46
|
+
# with bytes that are not the file, :timeout by never answering, :down
|
|
47
|
+
# by refusing the connection; a node not listed is :down.
|
|
48
|
+
#
|
|
49
|
+
# @param nodes [Hash{String => ActiveStorage::Service::MultiNodeDiskService, Symbol}]
|
|
50
|
+
# @param remote_addr [String] the caller's IP as the endpoint sees it
|
|
51
|
+
# @yieldparam requests [Array<Hash>] method, node, url and header names (never values)
|
|
52
|
+
def multi_node_disk_cluster(nodes = {}, remote_addr: "127.0.0.1", **named, &block)
|
|
53
|
+
requests = []
|
|
54
|
+
client = Cluster.new(nodes.merge(named.transform_keys(&:to_s)), remote_addr, requests)
|
|
55
|
+
MultiNodeDisk.with_config(http_client: client) { block.call(requests) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The fake transport behind multi_node_disk_cluster.
|
|
59
|
+
class Cluster
|
|
60
|
+
def initialize(nodes, remote_addr, requests)
|
|
61
|
+
@nodes = nodes
|
|
62
|
+
@remote_addr = remote_addr
|
|
63
|
+
@requests = requests
|
|
64
|
+
@lock = Mutex.new
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def call(method, url, headers:, node:, open_timeout:, read_timeout:)
|
|
68
|
+
@lock.synchronize { @requests << {method: method, node: node, url: url, headers: headers.keys} }
|
|
69
|
+
behaviour = @nodes.fetch(node, :down)
|
|
70
|
+
case behaviour
|
|
71
|
+
when :missing then Response.new(404, "", {NODE_HEADER => node})
|
|
72
|
+
when :wrong then Response.new(200, "not the file", {NODE_HEADER => node})
|
|
73
|
+
when :timeout then sleep
|
|
74
|
+
when :down then raise Errno::ECONNREFUSED, node
|
|
75
|
+
else serve(behaviour, method, url, headers)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def serve(service, method, url, headers)
|
|
82
|
+
# The engine's own path: whatever precedes the mount (a public
|
|
83
|
+
# prefix such as /app) is the host's proxy's business.
|
|
84
|
+
path = URI(url).path.sub(/\A.*?#{Regexp.escape(MultiNodeDisk.mount_path)}/, "")
|
|
85
|
+
env = Rack::MockRequest.env_for(path, :method => method.to_s.upcase, "REMOTE_ADDR" => @remote_addr)
|
|
86
|
+
headers.each { |name, value| env["HTTP_#{name.upcase.tr("-", "_")}"] = value }
|
|
87
|
+
env[SERVICE_ENV_KEY] = service
|
|
88
|
+
status, response_headers, body = Engine.call(env)
|
|
89
|
+
content = +""
|
|
90
|
+
body.each { |part| content << part }
|
|
91
|
+
body.close if body.respond_to?(:close)
|
|
92
|
+
Response.new(status, content, response_headers.to_h)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveStorage
|
|
4
|
+
module MultiNodeDisk
|
|
5
|
+
# Some node that may hold the file did not answer as itself: a
|
|
6
|
+
# connection error, a timeout, a 5xx, a refusal, or another node
|
|
7
|
+
# answering in its place. Worth retrying.
|
|
8
|
+
class Unavailable < ActiveStorage::Error
|
|
9
|
+
include FileError
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def describe = "#{node} is not answering"
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
require "rails"
|
|
5
|
+
require "active_storage"
|
|
6
|
+
require "active_storage/engine"
|
|
7
|
+
require "action_controller"
|
|
8
|
+
require "zeitwerk"
|
|
9
|
+
|
|
10
|
+
loader = Zeitwerk::Loader.for_gem_extension(ActiveStorage)
|
|
11
|
+
# Active Storage's Configurator requires the service by path
|
|
12
|
+
# (`service: MultiNodeDisk` in storage.yml), as it does S3Service.
|
|
13
|
+
loader.ignore("#{__dir__}/service")
|
|
14
|
+
# Active Job is optional: the job loads only in :job delete mode.
|
|
15
|
+
loader.ignore("#{__dir__}/multi_node_disk/delete_job.rb")
|
|
16
|
+
loader.setup
|
|
17
|
+
# Also by constant, for code that names the class before storage.yml built it.
|
|
18
|
+
ActiveStorage::Service.autoload(:MultiNodeDiskService, "active_storage/service/multi_node_disk_service")
|
|
19
|
+
|
|
20
|
+
module ActiveStorage
|
|
21
|
+
# Helpers for ActiveStorage::Service::MultiNodeDiskService, a Disk service
|
|
22
|
+
# for apps on several nodes, each with its own disk.
|
|
23
|
+
#
|
|
24
|
+
# 1. Upload: Disk writes the file to this node's disk.
|
|
25
|
+
# 2. Read, exist?: this disk first; else every other node is asked in
|
|
26
|
+
# parallel (FanOut), and the first answer that matches the blob's size
|
|
27
|
+
# and checksum wins.
|
|
28
|
+
# 3. Delete: this disk, then every other node.
|
|
29
|
+
# 4. A node's endpoint (BlobsController) answers from its own disk only,
|
|
30
|
+
# so a hop never fans out.
|
|
31
|
+
#
|
|
32
|
+
# Defaults are plain Rails 8.1; a host changes them once, in an
|
|
33
|
+
# initializer (`ActiveStorage::MultiNodeDisk.configure`).
|
|
34
|
+
module MultiNodeDisk
|
|
35
|
+
# Every endpoint response names the node that answered.
|
|
36
|
+
NODE_HEADER = "X-Multi-Node-Disk-Node"
|
|
37
|
+
# Rack env key naming the service an in-process request is answered from
|
|
38
|
+
# (TestHelper). HTTP cannot set it: header keys are HTTP_*.
|
|
39
|
+
SERVICE_ENV_KEY = "active_storage_multi_node_disk.service"
|
|
40
|
+
|
|
41
|
+
class << self
|
|
42
|
+
# @return [ActiveStorage::MultiNodeDisk::Configuration]
|
|
43
|
+
def config
|
|
44
|
+
@config ||= Configuration.new
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Sets the hooks, in an initializer.
|
|
48
|
+
#
|
|
49
|
+
# @yieldparam config [ActiveStorage::MultiNodeDisk::Configuration]
|
|
50
|
+
# @raise [ConfigurationError] once the app has booted (frozen)
|
|
51
|
+
def configure
|
|
52
|
+
raise ConfigurationError, "configure runs in an initializer; the configuration is frozen after boot" if config.frozen?
|
|
53
|
+
|
|
54
|
+
yield config
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Validates and freezes the configuration (the engine, after boot),
|
|
58
|
+
# and checks storage.yml: at most one MultiNodeDisk service (the
|
|
59
|
+
# endpoint serves one disk root), with a node list.
|
|
60
|
+
#
|
|
61
|
+
# @param services [Hash] storage.yml, by service name
|
|
62
|
+
# @return [ActiveStorage::MultiNodeDisk::Configuration]
|
|
63
|
+
def finalize!(services = service_configurations)
|
|
64
|
+
check_services!(services)
|
|
65
|
+
return config if config.frozen?
|
|
66
|
+
|
|
67
|
+
config.validate!.freeze
|
|
68
|
+
warn_unprotected
|
|
69
|
+
config
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# The cache of node addresses for the :ip check.
|
|
73
|
+
#
|
|
74
|
+
# @return [NodeAddresses]
|
|
75
|
+
def node_addresses
|
|
76
|
+
@node_addresses ||= NodeAddresses.new
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
attr_writer :node_addresses
|
|
80
|
+
|
|
81
|
+
# Where the endpoint is mounted: under Active Storage's routes prefix.
|
|
82
|
+
#
|
|
83
|
+
# @return [String]
|
|
84
|
+
def mount_path
|
|
85
|
+
"#{ActiveStorage.routes_prefix || "/rails/active_storage"}/multi_node_disk"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Runs the block with some options replaced (tests).
|
|
89
|
+
#
|
|
90
|
+
# @param overrides [Hash{Symbol => Object}]
|
|
91
|
+
def with_config(**overrides)
|
|
92
|
+
previous = config
|
|
93
|
+
@config = previous.merge(**overrides)
|
|
94
|
+
yield
|
|
95
|
+
ensure
|
|
96
|
+
@config = previous
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# The app's MultiNodeDisk service (storage.yml has at most one).
|
|
100
|
+
#
|
|
101
|
+
# @return [ActiveStorage::Service::MultiNodeDiskService, nil]
|
|
102
|
+
def service
|
|
103
|
+
name = service_configurations.find { |_, options| options.to_h.with_indifferent_access[:service].to_s == "MultiNodeDisk" }&.first
|
|
104
|
+
ActiveStorage::Blob.services.fetch(name) if name
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# storage.yml, read as Active Storage reads it (which it does only
|
|
108
|
+
# when ActiveStorage::Blob first loads).
|
|
109
|
+
#
|
|
110
|
+
# @return [Hash]
|
|
111
|
+
def service_configurations
|
|
112
|
+
configured = Rails.configuration.active_storage.service_configurations
|
|
113
|
+
return configured.to_h if configured
|
|
114
|
+
|
|
115
|
+
file = Rails.root.join("config/storage/#{Rails.env}.yml")
|
|
116
|
+
file = Rails.root.join("config/storage.yml") unless file.exist?
|
|
117
|
+
file.exist? ? ActiveSupport::ConfigurationFile.parse(file).to_h : {}
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Runs the block on behalf of someone (a job's user): remote fetches and
|
|
121
|
+
# deletes in it record them, here and on the node that answers.
|
|
122
|
+
#
|
|
123
|
+
# @param requester [Hash, nil] e.g. {id:, label:, type:}; never a secret
|
|
124
|
+
def with_requester(requester, &)
|
|
125
|
+
Requester.with(requester, &)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# @return [Hash, nil] the requester with_requester set
|
|
129
|
+
def current_requester
|
|
130
|
+
Requester.current
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# The shared node token from Rails credentials (`multi_node_disk.token`).
|
|
134
|
+
#
|
|
135
|
+
# @return [String, nil]
|
|
136
|
+
def credentials_token
|
|
137
|
+
Rails.application.credentials.dig(:multi_node_disk, :token).presence
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Instruments `<event>.multi_node_disk`. Payloads never carry a token.
|
|
141
|
+
#
|
|
142
|
+
# @param event [Symbol]
|
|
143
|
+
# @param payload [Hash]
|
|
144
|
+
def instrument(event, payload = {}, &)
|
|
145
|
+
ActiveSupport::Notifications.instrument("#{event}.multi_node_disk", payload, &)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
private
|
|
149
|
+
|
|
150
|
+
# One warning at boot in production while the endpoint has no check.
|
|
151
|
+
def warn_unprotected
|
|
152
|
+
return unless Rails.env.production? && service&.auth_modes == [:none]
|
|
153
|
+
|
|
154
|
+
Rails.logger&.warn("[ActiveStorage::MultiNodeDisk] auth is :none: anyone who can reach #{mount_path} can read and delete files. Set config.auth to :token, :ip or [:ip, :token].")
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def check_services!(services)
|
|
158
|
+
ours = services.to_h.select { |_, options| options.to_h.with_indifferent_access[:service].to_s == "MultiNodeDisk" }
|
|
159
|
+
raise ConfigurationError, "storage.yml has #{ours.size} MultiNodeDisk services (#{ours.keys.join(", ")}); one serves the endpoint" if ours.size > 1
|
|
160
|
+
|
|
161
|
+
ours.each do |name, options|
|
|
162
|
+
next unless options.to_h.with_indifferent_access[:nodes].nil? && config.nodes.nil?
|
|
163
|
+
|
|
164
|
+
raise ConfigurationError, "storage.yml #{name}: MultiNodeDisk needs nodes: (or config.nodes)"
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Loaded now, so Rails sees the engine (its controller, job and routes).
|
|
172
|
+
require "active_storage/multi_node_disk/engine"
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_storage/service/disk_service"
|
|
4
|
+
require "active_storage/multi_node_disk"
|
|
5
|
+
|
|
6
|
+
module ActiveStorage
|
|
7
|
+
# = Active Storage \Multi-Node \Disk \Service
|
|
8
|
+
#
|
|
9
|
+
# Disk for an app on several nodes, each with its own disk. A file stays on
|
|
10
|
+
# the disk of the node that received it. When a file is not on this disk,
|
|
11
|
+
# every other node is asked at once and the first copy that matches the
|
|
12
|
+
# blob wins; a delete deletes here and on every other node. Nothing is
|
|
13
|
+
# recorded, so existing blobs work as they are.
|
|
14
|
+
#
|
|
15
|
+
# local:
|
|
16
|
+
# service: MultiNodeDisk
|
|
17
|
+
# root: <%= Rails.root.join("storage") %>
|
|
18
|
+
# nodes: [app1.internal, app2.internal]
|
|
19
|
+
#
|
|
20
|
+
# Differs from Disk only in download, download_chunk, exist?, delete and
|
|
21
|
+
# open, each calling super for a file on this disk. Keys without a blob
|
|
22
|
+
# row (variants) and url, delete_prefixed and compose stay Disk's: local.
|
|
23
|
+
class Service::MultiNodeDiskService < Service::DiskService
|
|
24
|
+
LOCAL_DELETE = :active_storage_multi_node_disk_local_delete
|
|
25
|
+
|
|
26
|
+
# @param root [String] this node's storage directory, as Disk
|
|
27
|
+
# @param nodes [Array<String>, nil] every node, this one included (else config.nodes)
|
|
28
|
+
# @param node [String, nil] this node (else config.current_node)
|
|
29
|
+
# @param open_timeout [Numeric, nil] seconds (else config.open_timeout)
|
|
30
|
+
# @param read_timeout [Numeric, nil] seconds (else config.read_timeout)
|
|
31
|
+
# @param auth [Symbol, Array<Symbol>, nil] the endpoint's checks (else config.auth)
|
|
32
|
+
# @param allowed_ips [Array<String>, nil] for :ip (else config.allowed_ips)
|
|
33
|
+
def initialize(root:, public: false, nodes: nil, node: nil, open_timeout: nil, read_timeout: nil, auth: nil, allowed_ips: nil, **options)
|
|
34
|
+
super(root: root, public: public, **options)
|
|
35
|
+
@nodes_option = nodes
|
|
36
|
+
@node_option = node
|
|
37
|
+
@open_timeout = seconds!(:open_timeout, open_timeout)
|
|
38
|
+
@read_timeout = seconds!(:read_timeout, read_timeout)
|
|
39
|
+
@auth = auth && MultiNodeDisk::Configuration.auth_modes(auth)
|
|
40
|
+
@allowed_ips = MultiNodeDisk::Configuration.ip_list(allowed_ips)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Disk's upload. A file that fails its checksum is deleted here only,
|
|
44
|
+
# never on the other nodes (Disk cleans up through #delete).
|
|
45
|
+
def upload(key, io, checksum: nil, **)
|
|
46
|
+
previous = ActiveSupport::IsolatedExecutionState[LOCAL_DELETE]
|
|
47
|
+
ActiveSupport::IsolatedExecutionState[LOCAL_DELETE] = true
|
|
48
|
+
super
|
|
49
|
+
ensure
|
|
50
|
+
ActiveSupport::IsolatedExecutionState[LOCAL_DELETE] = previous
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Disk's download. A whole file is checked against its blob; a file not
|
|
54
|
+
# on this disk comes from the first node with a matching copy.
|
|
55
|
+
def download(key, &block)
|
|
56
|
+
blob = remote_blob(key)
|
|
57
|
+
if blob.nil?
|
|
58
|
+
block ? super : verify_local(key, super)
|
|
59
|
+
elsif block
|
|
60
|
+
instrument(:streaming_download, key: key) { each_chunk(fetch(key, blob), &block) }
|
|
61
|
+
else
|
|
62
|
+
instrument(:download, key: key) { fetch(key, blob) }
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def download_chunk(key, range)
|
|
67
|
+
blob = remote_blob(key)
|
|
68
|
+
return super unless blob
|
|
69
|
+
|
|
70
|
+
instrument(:download_chunk, key: key, range: range) { fetch(key, blob).byteslice(range) }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Whether any node has the file (presence, as Disk: not its checksum).
|
|
74
|
+
def exist?(key)
|
|
75
|
+
blob = remote_blob(key)
|
|
76
|
+
return super unless blob
|
|
77
|
+
|
|
78
|
+
instrument(:exist, key: key) do |payload|
|
|
79
|
+
payload[:exist] = exist_elsewhere?(key, blob)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Deletes the file here, then on every other node: inline in :sync mode
|
|
84
|
+
# (raising, as other services raise on a failed delete, once every node
|
|
85
|
+
# was tried), or through DeleteJob in :job mode.
|
|
86
|
+
#
|
|
87
|
+
# @raise [MultiNodeDisk::Unavailable] in :sync mode, naming the nodes that did not confirm
|
|
88
|
+
def delete(key)
|
|
89
|
+
super
|
|
90
|
+
return if ActiveSupport::IsolatedExecutionState[LOCAL_DELETE] || other_nodes.empty?
|
|
91
|
+
|
|
92
|
+
config = MultiNodeDisk.config
|
|
93
|
+
if config.delete_mode.to_s == "job"
|
|
94
|
+
queue = config.delete_job_queue || ActiveStorage.queues[:purge]
|
|
95
|
+
job = config.delete_job
|
|
96
|
+
requester = MultiNodeDisk.current_requester || config.audit_requester&.call(nil)
|
|
97
|
+
(queue ? job.set(queue: queue) : job).perform_later(name.to_s, key, other_nodes, requester&.as_json)
|
|
98
|
+
else
|
|
99
|
+
failed = delete_elsewhere(key)
|
|
100
|
+
raise config.error_mapper.call(MultiNodeDisk::Unavailable.new(nodes: failed, key: key, reason: "delete not confirmed")) if failed.any?
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Disk's open, with the integrity check raising the gem's Mismatch
|
|
105
|
+
# (through the host's error_mapper). Errors from the block are its own.
|
|
106
|
+
def open(key, checksum: nil, verify: true, **options)
|
|
107
|
+
super(key, checksum: checksum, verify: false, **options) do |file|
|
|
108
|
+
verify_file(key, file, checksum) if verify
|
|
109
|
+
yield file
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# @return [String] this node's name
|
|
114
|
+
# @raise [MultiNodeDisk::ConfigurationError] when it resolves blank
|
|
115
|
+
def node
|
|
116
|
+
found = (@node_option.presence || MultiNodeDisk.config.resolve(:current_node)).to_s
|
|
117
|
+
raise MultiNodeDisk::ConfigurationError, "MultiNodeDisk: the current node resolved blank" if found.empty?
|
|
118
|
+
|
|
119
|
+
found
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# @return [Array<String>] every node, this one included
|
|
123
|
+
# @raise [MultiNodeDisk::ConfigurationError] when no list is set
|
|
124
|
+
def nodes
|
|
125
|
+
list = @nodes_option.nil? ? MultiNodeDisk.config.resolve(:nodes) : @nodes_option
|
|
126
|
+
raise MultiNodeDisk::ConfigurationError, "MultiNodeDisk needs nodes: in storage.yml (or config.nodes)" if list.nil?
|
|
127
|
+
|
|
128
|
+
Array(list).map { |entry| entry.to_s.strip }.reject(&:empty?).uniq
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# @return [Array<Symbol>] the endpoint's checks: [:none], [:token], [:ip] or [:ip, :token]
|
|
132
|
+
def auth_modes
|
|
133
|
+
@auth || MultiNodeDisk::Configuration.auth_modes(MultiNodeDisk.config.auth)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# @return [Array<IPAddr>] who may call the endpoint under :ip
|
|
137
|
+
def allowed_ips
|
|
138
|
+
@allowed_ips || MultiNodeDisk::Configuration.ip_list(MultiNodeDisk.config.allowed_ips) || MultiNodeDisk.node_addresses.for(nodes)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# @param key [String]
|
|
142
|
+
# @return [Boolean] the file is on this node's disk
|
|
143
|
+
def local?(key)
|
|
144
|
+
File.exist?(path_for(key))
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Sends a delete to some nodes (all the others by default).
|
|
148
|
+
#
|
|
149
|
+
# @param key [String]
|
|
150
|
+
# @param nodes [Array<String>]
|
|
151
|
+
# @return [Array<String>] the nodes that did not confirm
|
|
152
|
+
def delete_elsewhere(key, nodes: other_nodes)
|
|
153
|
+
started = clock
|
|
154
|
+
blob = ActiveStorage::Blob.find_by(key: key)
|
|
155
|
+
outcomes = ask(nodes, :delete, key, blob, :delete) do |answering, response|
|
|
156
|
+
(response.status == 204 && answered_by?(answering, response)) ? :deleted : :failed
|
|
157
|
+
end
|
|
158
|
+
MultiNodeDisk.instrument(:delete, key: key, outcomes: outcomes)
|
|
159
|
+
failed = outcomes.select { |_, outcome| outcome == :failed }.keys
|
|
160
|
+
audit(:remote_delete, failed.any? ? :error : :deleted, key, blob, outcomes, started)
|
|
161
|
+
failed
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
private
|
|
165
|
+
|
|
166
|
+
def other_nodes = nodes - [node]
|
|
167
|
+
|
|
168
|
+
def seconds!(option, value)
|
|
169
|
+
return if value.nil?
|
|
170
|
+
return value if MultiNodeDisk::Configuration.seconds?(value)
|
|
171
|
+
|
|
172
|
+
raise MultiNodeDisk::ConfigurationError, "MultiNodeDisk #{option}: in storage.yml must be a positive, finite number of seconds"
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def open_timeout = @open_timeout || MultiNodeDisk.config.open_timeout
|
|
176
|
+
|
|
177
|
+
def read_timeout = @read_timeout || MultiNodeDisk.config.read_timeout
|
|
178
|
+
|
|
179
|
+
# The blob to look for elsewhere: nil when the file is here, or when no
|
|
180
|
+
# blob row has the key (a variant: Disk's own, local behaviour).
|
|
181
|
+
def remote_blob(key)
|
|
182
|
+
return if local?(key)
|
|
183
|
+
|
|
184
|
+
ActiveStorage::Blob.find_by(key: key)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# The first copy from another node that matches the blob.
|
|
188
|
+
def fetch(key, blob)
|
|
189
|
+
started = clock
|
|
190
|
+
winner = nil
|
|
191
|
+
outcomes = ask(other_nodes, :get, key, blob, :read) do |answering, response|
|
|
192
|
+
next :failed unless response.status == 200
|
|
193
|
+
next(winner = response.body) && :hit if matches?(blob, response.body)
|
|
194
|
+
|
|
195
|
+
MultiNodeDisk.instrument(:mismatch, key: key, node: answering, byte_size: response.body.to_s.bytesize)
|
|
196
|
+
:mismatch
|
|
197
|
+
end
|
|
198
|
+
MultiNodeDisk.instrument(:fetch, key: key, outcomes: outcomes)
|
|
199
|
+
audit(:fetch, fetch_result(winner, outcomes), key, blob, outcomes, started)
|
|
200
|
+
winner || raise_for(outcomes, key: key, filename: blob.filename)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def fetch_result(winner, outcomes)
|
|
204
|
+
return :served if winner
|
|
205
|
+
return :error if outcomes.value?(:failed)
|
|
206
|
+
|
|
207
|
+
outcomes.value?(:mismatch) ? :mismatch : :not_found
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def audit(action, result, key, blob, outcomes, started)
|
|
211
|
+
MultiNodeDisk::AuditLog.record({
|
|
212
|
+
action: action, result: result, key: key, filename: blob&.filename&.to_s, byte_size: blob&.byte_size,
|
|
213
|
+
node: outcomes.key(:hit), from_node: node, outcomes: outcomes, auth: auth_modes, hop: false,
|
|
214
|
+
duration_ms: ((clock - started) * 1000).round(1)
|
|
215
|
+
})
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def clock = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
219
|
+
|
|
220
|
+
def exist_elsewhere?(key, blob)
|
|
221
|
+
outcomes = ask(other_nodes, :head, key, blob, :read) { |_, response| (response.status == 200) ? :hit : :failed }
|
|
222
|
+
MultiNodeDisk.instrument(:exist, key: key, outcomes: outcomes)
|
|
223
|
+
return true if outcomes.value?(:hit)
|
|
224
|
+
return false unless outcomes.value?(:failed)
|
|
225
|
+
|
|
226
|
+
raise_for(outcomes, key: key, filename: blob.filename)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Asks the nodes at once. The block classifies a response; a 404 from
|
|
230
|
+
# the node addressed is :missing; an error, another node answering 404,
|
|
231
|
+
# a node with no token and a node that never answered are :failed.
|
|
232
|
+
# Stops at the first :hit.
|
|
233
|
+
#
|
|
234
|
+
# @return [Hash{String => Symbol}] each node's outcome
|
|
235
|
+
def ask(targets, method, key, blob, action)
|
|
236
|
+
requests = targets.to_h { |target| [target, request_for(method, target, key, blob, action)] }
|
|
237
|
+
outcomes = {}
|
|
238
|
+
MultiNodeDisk::FanOut.run(requests.compact, deadline: open_timeout + read_timeout) do |answering, result|
|
|
239
|
+
outcomes[answering] = classify(answering, result) { |response| yield(answering, response) }
|
|
240
|
+
outcomes[answering] == :hit
|
|
241
|
+
end
|
|
242
|
+
targets.each { |target| outcomes[target] ||= outcomes.value?(:hit) ? :skipped : :failed }
|
|
243
|
+
outcomes
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def classify(answering, result)
|
|
247
|
+
return :failed unless result.is_a?(MultiNodeDisk::Response)
|
|
248
|
+
return :missing if result.status == 404 && answered_by?(answering, result)
|
|
249
|
+
|
|
250
|
+
yield result
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def answered_by?(target, response)
|
|
254
|
+
response.header(MultiNodeDisk::NODE_HEADER) == target
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# The hooks run here, on the caller's thread; the callable returned runs
|
|
258
|
+
# only the transport. Under :token, nil when no token is configured for
|
|
259
|
+
# the node (so it counts as failed, unasked).
|
|
260
|
+
def request_for(method, target, key, blob, action)
|
|
261
|
+
config = MultiNodeDisk.config
|
|
262
|
+
url = config.url_for.call(target, key).to_s
|
|
263
|
+
raise MultiNodeDisk::ConfigurationError, "MultiNodeDisk url_for must return an https URL" unless url.start_with?("https://")
|
|
264
|
+
|
|
265
|
+
headers = {config.hop_header => node}
|
|
266
|
+
requester = MultiNodeDisk::Requester.encode(MultiNodeDisk.current_requester || config.audit_requester&.call(nil))
|
|
267
|
+
headers[config.requester_header] = requester if requester
|
|
268
|
+
if auth_modes.include?(:token)
|
|
269
|
+
token = config.resolve(:token_for, target, blob, action).presence
|
|
270
|
+
return unless token
|
|
271
|
+
|
|
272
|
+
headers["Authorization"] = "Bearer #{token}"
|
|
273
|
+
end
|
|
274
|
+
client = config.http_client
|
|
275
|
+
timeouts = {open_timeout: open_timeout, read_timeout: read_timeout}
|
|
276
|
+
-> { MultiNodeDisk::Response.new(*client.call(method, url, headers: headers, node: target, **timeouts).to_a) }
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def raise_for(outcomes, key:, filename:)
|
|
280
|
+
failed = outcomes.select { |_, outcome| outcome == :failed }.keys
|
|
281
|
+
mismatched = outcomes.select { |_, outcome| outcome == :mismatch }.keys
|
|
282
|
+
error =
|
|
283
|
+
if failed.any? then MultiNodeDisk::Unavailable.new(nodes: failed, key: key, filename: filename)
|
|
284
|
+
elsif mismatched.any? then MultiNodeDisk::Mismatch.new(nodes: mismatched, key: key, filename: filename)
|
|
285
|
+
else MultiNodeDisk::NotHere.new(nodes: nodes, key: key, filename: filename)
|
|
286
|
+
end
|
|
287
|
+
raise MultiNodeDisk.config.error_mapper.call(error)
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def verify_local(key, bytes)
|
|
291
|
+
blob = ActiveStorage::Blob.find_by(key: key)
|
|
292
|
+
return bytes if blob.nil? || matches?(blob, bytes)
|
|
293
|
+
|
|
294
|
+
mismatch_here(key, blob.filename)
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def verify_file(key, file, checksum)
|
|
298
|
+
return if checksum.present? && ActiveStorage.checksum_implementation.file(file.path).base64digest == checksum
|
|
299
|
+
|
|
300
|
+
mismatch_here(key, ActiveStorage::Blob.find_by(key: key)&.filename)
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def mismatch_here(key, filename)
|
|
304
|
+
MultiNodeDisk.instrument(:mismatch, key: key, node: node)
|
|
305
|
+
raise MultiNodeDisk.config.error_mapper.call(MultiNodeDisk::Mismatch.new(nodes: node, key: key, filename: filename))
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def matches?(blob, bytes)
|
|
309
|
+
bytes = bytes.to_s
|
|
310
|
+
bytes.bytesize == blob.byte_size &&
|
|
311
|
+
(blob.checksum.blank? || ActiveStorage.checksum_implementation.base64digest(bytes) == blob.checksum)
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def each_chunk(bytes)
|
|
315
|
+
offset = 0
|
|
316
|
+
while offset < bytes.bytesize
|
|
317
|
+
yield bytes.byteslice(offset, 5.megabytes)
|
|
318
|
+
offset += 5.megabytes
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module ActiveStorage
|
|
7
|
+
module MultiNodeDisk
|
|
8
|
+
# `bin/rails g active_storage:multi_node_disk:install [--audit]`: the
|
|
9
|
+
# initializer, and with --audit the host's migration for the audit
|
|
10
|
+
# table (as Blazer's install generator writes its own). Nothing here runs
|
|
11
|
+
# a migration.
|
|
12
|
+
class InstallGenerator < Rails::Generators::Base
|
|
13
|
+
include ActiveRecord::Generators::Migration
|
|
14
|
+
|
|
15
|
+
source_root File.join(__dir__, "templates")
|
|
16
|
+
|
|
17
|
+
class_option :audit, type: :boolean, default: false, desc: "Add the migration for the :table audit sink"
|
|
18
|
+
|
|
19
|
+
# @return [void]
|
|
20
|
+
def copy_initializer
|
|
21
|
+
template "initializer.rb", "config/initializers/active_storage_multi_node_disk.rb"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @return [void]
|
|
25
|
+
def copy_audit_migration
|
|
26
|
+
return unless options[:audit]
|
|
27
|
+
|
|
28
|
+
migration_template "create_audits.rb", "db/migrate/create_active_storage_multi_node_disk_audits.rb", migration_version: migration_version
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def migration_version
|
|
34
|
+
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def table_name
|
|
38
|
+
"#{ActiveStorage::MultiNodeDisk.config.table_name_prefix}audits"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Short and explicit: Oracle before 12.2 allows 30 characters.
|
|
42
|
+
def index_name
|
|
43
|
+
"i_#{ActiveStorage::MultiNodeDisk.config.table_name_prefix.split("_").map(&:first).join}_audits_created_at"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|