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.
Files changed (33) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +43 -0
  3. data/CONTRIBUTING.md +89 -0
  4. data/LICENSE +21 -0
  5. data/README.md +249 -0
  6. data/SECURITY.md +33 -0
  7. data/app/controllers/active_storage/multi_node_disk/blobs_controller.rb +166 -0
  8. data/config/routes.rb +10 -0
  9. data/lib/active_storage/multi_node_disk/audit.rb +15 -0
  10. data/lib/active_storage/multi_node_disk/audit_log.rb +48 -0
  11. data/lib/active_storage/multi_node_disk/configuration.rb +217 -0
  12. data/lib/active_storage/multi_node_disk/configuration_error.rb +8 -0
  13. data/lib/active_storage/multi_node_disk/delete_job.rb +27 -0
  14. data/lib/active_storage/multi_node_disk/engine.rb +24 -0
  15. data/lib/active_storage/multi_node_disk/fan_out.rb +76 -0
  16. data/lib/active_storage/multi_node_disk/file_error.rb +36 -0
  17. data/lib/active_storage/multi_node_disk/file_sink.rb +38 -0
  18. data/lib/active_storage/multi_node_disk/mismatch.rb +15 -0
  19. data/lib/active_storage/multi_node_disk/net_http_client.rb +31 -0
  20. data/lib/active_storage/multi_node_disk/node_addresses.rb +50 -0
  21. data/lib/active_storage/multi_node_disk/not_here.rb +15 -0
  22. data/lib/active_storage/multi_node_disk/requester.rb +50 -0
  23. data/lib/active_storage/multi_node_disk/response.rb +16 -0
  24. data/lib/active_storage/multi_node_disk/test_helper.rb +97 -0
  25. data/lib/active_storage/multi_node_disk/unavailable.rb +16 -0
  26. data/lib/active_storage/multi_node_disk/version.rb +7 -0
  27. data/lib/active_storage/multi_node_disk.rb +172 -0
  28. data/lib/active_storage/service/multi_node_disk_service.rb +322 -0
  29. data/lib/activestorage-multi_node_disk.rb +4 -0
  30. data/lib/generators/active_storage/multi_node_disk/install_generator.rb +47 -0
  31. data/lib/generators/active_storage/multi_node_disk/templates/create_audits.rb.tt +33 -0
  32. data/lib/generators/active_storage/multi_node_disk/templates/initializer.rb.tt +26 -0
  33. 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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorage
4
+ module MultiNodeDisk
5
+ VERSION = "0.1.0"
6
+ end
7
+ 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,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Bundler requires a gem by its name; the code lives under active_storage/.
4
+ require "active_storage/multi_node_disk"
@@ -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