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,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,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorage
4
+ module MultiNodeDisk
5
+ # A hook is missing or of the wrong kind (raised at boot).
6
+ class ConfigurationError < StandardError; end
7
+ end
8
+ 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