active_storage_quota 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 (41) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +52 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +404 -0
  5. data/app/controllers/active_storage_quota/direct_uploads_controller.rb +96 -0
  6. data/db/migrate/20260917000001_create_active_storage_quota_tables.rb +72 -0
  7. data/db/migrate/20260918000001_create_active_storage_quota_charges.rb +45 -0
  8. data/lib/active_storage_quota/account.rb +200 -0
  9. data/lib/active_storage_quota/attachable.rb +44 -0
  10. data/lib/active_storage_quota/attachment_accounting.rb +144 -0
  11. data/lib/active_storage_quota/audit/counter_audit.rb +62 -0
  12. data/lib/active_storage_quota/audit/finding.rb +17 -0
  13. data/lib/active_storage_quota/audit/ledger_audit.rb +164 -0
  14. data/lib/active_storage_quota/audit/result.rb +106 -0
  15. data/lib/active_storage_quota/audit.rb +75 -0
  16. data/lib/active_storage_quota/backfill/cursor.rb +64 -0
  17. data/lib/active_storage_quota/backfill/result.rb +146 -0
  18. data/lib/active_storage_quota/backfill.rb +310 -0
  19. data/lib/active_storage_quota/blob_accounting.rb +44 -0
  20. data/lib/active_storage_quota/charge.rb +379 -0
  21. data/lib/active_storage_quota/configuration.rb +37 -0
  22. data/lib/active_storage_quota/definition.rb +100 -0
  23. data/lib/active_storage_quota/engine.rb +28 -0
  24. data/lib/active_storage_quota/errors.rb +115 -0
  25. data/lib/active_storage_quota/owner.rb +154 -0
  26. data/lib/active_storage_quota/owner_resolver.rb +402 -0
  27. data/lib/active_storage_quota/owner_source.rb +88 -0
  28. data/lib/active_storage_quota/reconciliation/budget.rb +70 -0
  29. data/lib/active_storage_quota/reconciliation/counter_reconciler.rb +78 -0
  30. data/lib/active_storage_quota/reconciliation/ledger_reconciler.rb +250 -0
  31. data/lib/active_storage_quota/reconciliation/result.rb +107 -0
  32. data/lib/active_storage_quota/reconciliation.rb +72 -0
  33. data/lib/active_storage_quota/record.rb +20 -0
  34. data/lib/active_storage_quota/reservation.rb +297 -0
  35. data/lib/active_storage_quota/version.rb +5 -0
  36. data/lib/active_storage_quota.rb +207 -0
  37. data/lib/tasks/active_storage_quota.rake +11 -0
  38. data/lib/tasks/active_storage_quota_audit.rake +15 -0
  39. data/lib/tasks/active_storage_quota_backfill.rake +104 -0
  40. data/lib/tasks/active_storage_quota_reconcile.rake +100 -0
  41. metadata +150 -0
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorageQuota
4
+ # Base class for every error raised by this gem. Rescue this to catch them all.
5
+ class Error < StandardError; end
6
+
7
+ # Raised when the gem is configured with a value it cannot use, for example a
8
+ # negative +reservation_ttl+ or an unusable +limit:+ option.
9
+ class ConfigurationError < Error; end
10
+
11
+ # Raised by +reserve_storage!+ when the requested bytes do not fit within the
12
+ # limit that was resolved for that reservation.
13
+ #
14
+ # Carries the numbers involved so callers can render a message without
15
+ # re-querying:
16
+ #
17
+ # rescue ActiveStorageQuota::QuotaExceeded => e
18
+ # render json: { error: e.message, remaining: e.remaining_bytes }, status: :payload_too_large
19
+ # end
20
+ class QuotaExceeded < Error
21
+ attr_reader :owner, :scope_name, :requested_bytes, :limit_bytes, :used_bytes, :reserved_bytes
22
+
23
+ def initialize(owner:, scope_name:, requested_bytes:, limit_bytes:, used_bytes:, reserved_bytes:)
24
+ @owner = owner
25
+ @scope_name = scope_name
26
+ @requested_bytes = requested_bytes
27
+ @limit_bytes = limit_bytes
28
+ @used_bytes = used_bytes
29
+ @reserved_bytes = reserved_bytes
30
+
31
+ super(build_message)
32
+ end
33
+
34
+ # Bytes still available under the resolved limit. Never negative.
35
+ def remaining_bytes
36
+ [ limit_bytes - used_bytes - reserved_bytes, 0 ].max
37
+ end
38
+
39
+ private
40
+ def build_message
41
+ "storage quota exceeded for #{owner.class.name}##{owner.id} " \
42
+ "(scope: #{scope_name}): requested #{requested_bytes} bytes, " \
43
+ "#{remaining_bytes} of #{limit_bytes} available " \
44
+ "(#{used_bytes} used, #{reserved_bytes} reserved)"
45
+ end
46
+ end
47
+
48
+ # Raised when a reservation cannot make the requested state transition, for
49
+ # example committing one that was already released.
50
+ class InvalidReservation < Error; end
51
+
52
+ # Raised when a model declares +storage_quota_owner+ but the owner resolves to
53
+ # nil for a record being attached to.
54
+ #
55
+ # Declaring the macro opts the model into quota accounting, so an unresolvable
56
+ # owner is a bug rather than a state to tolerate: without this, creating a
57
+ # record with no parent, attaching to it, and assigning the parent afterwards
58
+ # would store bytes nobody is charged for.
59
+ class MissingQuotaOwner < Error
60
+ attr_reader :record, :owner_source
61
+
62
+ def initialize(message = nil, record: nil, owner_source: nil)
63
+ @record = record
64
+ @owner_source = owner_source
65
+
66
+ super(message || "#{record.class.name}##{record.id} declares " \
67
+ "storage_quota_owner #{owner_source.inspect}, which resolved to nil")
68
+ end
69
+ end
70
+
71
+ # Raised when backfill is asked to apply while some owners still have a real
72
+ # quota limit.
73
+ #
74
+ # Backfill writes charges without writing +used_bytes+, so counters understate
75
+ # the ledger until counter reconciliation runs. Enforcing against them in the
76
+ # meantime would admit uploads that should have been refused.
77
+ class EnforcementActive < Error
78
+ attr_reader :owners
79
+
80
+ def initialize(owners:)
81
+ @owners = owners
82
+
83
+ super("#{owners.size} owner(s) still have an active quota limit, and " \
84
+ "backfill leaves used_bytes incomplete until counter reconciliation " \
85
+ "runs. Let limits resolve to nil during adoption, or pass " \
86
+ "allow_active_enforcement: true to proceed anyway.")
87
+ end
88
+ end
89
+
90
+ # Raised when charge bookkeeping repeatedly loses a race to concurrent
91
+ # attach/detach activity on the same account and blob.
92
+ #
93
+ # Each attempt is correct in isolation; this means the row was created and
94
+ # destroyed underneath us more times than the bounded retry allows. On the
95
+ # attachment-create path it fails closed, rolling the attachment back.
96
+ class ConcurrentAccountingConflict < Error; end
97
+
98
+ # Raised when a reservation's bytes cannot be moved because the account no
99
+ # longer holds them: the reservation says N bytes are reserved, but
100
+ # +reserved_bytes+ is below N.
101
+ #
102
+ # This means the counters have drifted from the reservations that explain
103
+ # them -- a bug, or an external write. The transaction is rolled back rather
104
+ # than repaired: silently adjusting counters here would hide the drift and
105
+ # make the real cause unfindable. Reconciliation is a separate, deliberate
106
+ # operation.
107
+ class CounterInvariantViolation < Error; end
108
+
109
+ # Raised when committing a reservation whose +expires_at+ has passed. This is
110
+ # deterministic and does not depend on whether the expiry sweeper has run.
111
+ #
112
+ # A subclass of InvalidReservation so callers that treat every unusable
113
+ # reservation the same way can rescue just the parent.
114
+ class ReservationExpired < InvalidReservation; end
115
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module ActiveStorageQuota
6
+ # The model-side API, mixed into any Active Record class that declares
7
+ # +has_storage_quota+.
8
+ #
9
+ # Every reader here is ADVISORY. They describe the quota as it stood when
10
+ # they ran, which is exactly what a progress bar or an admin screen needs,
11
+ # and exactly what an admission check must not rely on:
12
+ #
13
+ # # Racy. Two requests can both read the same free space and both proceed.
14
+ # company.documents.attach(file) if company.storage_remaining >= file.size
15
+ #
16
+ # Admitting an upload is the job of the reservation API, which performs the
17
+ # check and the claim as one atomic statement.
18
+ module Owner
19
+ extend ActiveSupport::Concern
20
+
21
+ # Extended onto ActiveRecord::Base so every model can declare a quota.
22
+ module Macro
23
+ # Declares this model as a storage quota owner.
24
+ #
25
+ # class Company < ApplicationRecord
26
+ # has_storage_quota limit: 5.gigabytes
27
+ # end
28
+ #
29
+ # +limit+ accepts an Integer number of bytes, a Symbol naming a method on
30
+ # the owner, a callable taking the owner or evaluated against it, or nil
31
+ # for unlimited. See ActiveStorageQuota::Definition.
32
+ #
33
+ # Subclasses may redeclare it; because the definition is held in a
34
+ # class_attribute, doing so does not affect the parent class.
35
+ def has_storage_quota(limit:)
36
+ include ActiveStorageQuota::Owner
37
+
38
+ self.storage_quota_definition = ActiveStorageQuota::Definition.new(limit: limit)
39
+ end
40
+ end
41
+
42
+ included do
43
+ # class_attribute, not a plain ivar: a subclass that redeclares its quota
44
+ # gets its own value instead of overwriting its parent's.
45
+ class_attribute :storage_quota_definition, instance_accessor: false
46
+
47
+ has_many :storage_quota_accounts,
48
+ as: :owner,
49
+ class_name: "ActiveStorageQuota::Account",
50
+ dependent: :destroy
51
+ end
52
+
53
+ # This owner's limit in bytes, or nil when unlimited.
54
+ def storage_limit
55
+ self.class.storage_quota_definition.resolve(self)
56
+ end
57
+
58
+ # Committed storage in bytes. Zero when no account row exists yet.
59
+ def storage_usage
60
+ storage_quota_account&.used_bytes || 0
61
+ end
62
+
63
+ # Storage claimed by in-flight uploads, in bytes. Zero when no account row
64
+ # exists yet.
65
+ def storage_reserved
66
+ storage_quota_account&.reserved_bytes || 0
67
+ end
68
+
69
+ # Bytes still available, floored at zero so a lowered limit reports no
70
+ # space rather than a negative number. Returns nil when unlimited.
71
+ #
72
+ # Advisory: see the note on this module. A value here is not permission to
73
+ # upload.
74
+ def storage_remaining
75
+ limit = storage_limit
76
+ return nil if limit.nil?
77
+
78
+ used, reserved = storage_quota_counters
79
+ [ limit - used - reserved, 0 ].max
80
+ end
81
+
82
+ # Whether this owner is currently over its limit. False when unlimited.
83
+ #
84
+ # This is a state reader, not an admission check: it answers "are we over
85
+ # budget right now", not "will the next upload fit". A limit lowered below
86
+ # existing usage makes this true, which is a normal state rather than an
87
+ # error.
88
+ def storage_quota_exceeded?
89
+ limit = storage_limit
90
+ return false if limit.nil?
91
+
92
+ used, reserved = storage_quota_counters
93
+ used + reserved > limit
94
+ end
95
+
96
+ # Claims +byte_size+ bytes against this owner's quota and returns the
97
+ # Reservation holding them.
98
+ #
99
+ # reservation = company.reserve_storage!(file.size)
100
+ # begin
101
+ # company.documents.attach(file)
102
+ # reservation.commit!
103
+ # rescue StandardError
104
+ # reservation.release!
105
+ # raise
106
+ # end
107
+ #
108
+ # This is the only race-safe way to admit an upload: the capacity test and
109
+ # the claim are a single statement, so concurrent callers across any number
110
+ # of processes cannot both be admitted into the same free space.
111
+ #
112
+ # Raises ActiveStorageQuota::QuotaExceeded if the bytes do not fit, and
113
+ # ArgumentError unless +byte_size+ is an exact non-negative Integer.
114
+ #
115
+ # The limit is resolved once, here, and enforced atomically against that
116
+ # value.
117
+ # Pass +blob:+ when holding capacity for a specific blob, as a direct-upload
118
+ # endpoint does. The attachment accounting then finds and consumes this hold
119
+ # instead of claiming capacity a second time.
120
+ def reserve_storage!(byte_size, blob: nil)
121
+ ActiveStorageQuota::Account.reserve!(
122
+ owner: self,
123
+ byte_size: byte_size,
124
+ limit_bytes: storage_limit,
125
+ blob_id: blob&.id
126
+ )
127
+ end
128
+
129
+ # The Account for +scope_name+, or nil when none exists.
130
+ #
131
+ # Reads never create rows, so an owner that has never stored anything costs
132
+ # nothing. When the association has already been loaded -- by
133
+ # Company.includes(:storage_quota_accounts), say -- the row is found in
134
+ # memory rather than with another query.
135
+ def storage_quota_account(scope_name = ActiveStorageQuota::DEFAULT_SCOPE)
136
+ scope_name = scope_name.to_s
137
+
138
+ if storage_quota_accounts.loaded?
139
+ storage_quota_accounts.find { |account| account.scope_name == scope_name }
140
+ else
141
+ storage_quota_accounts.find_by(scope_name: scope_name)
142
+ end
143
+ end
144
+
145
+ private
146
+ # Both counters from a single account lookup, so the readers that need
147
+ # both do not issue two queries for one answer.
148
+ def storage_quota_counters(scope_name = ActiveStorageQuota::DEFAULT_SCOPE)
149
+ account = storage_quota_account(scope_name)
150
+
151
+ [ account&.used_bytes || 0, account&.reserved_bytes || 0 ]
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,402 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorageQuota
4
+ # Maps Active Storage attachments to the owners, and then the quota accounts,
5
+ # that should be charged for them.
6
+ #
7
+ # Internal. Shared by auditing, reconciliation and backfill so that reflection
8
+ # rules are decided in exactly one place; not part of the public API.
9
+ #
10
+ # Two paths. Where a model declares its payer as a Symbol naming a plain
11
+ # +belongs_to+, the record-to-owner step is a SQL join and no attachment or
12
+ # record is instantiated. Everywhere else, records are loaded in batches and
13
+ # the declared source is resolved in Ruby.
14
+ #
15
+ # The fast path is deliberately hard to qualify for. A +belongs_to+ can
16
+ # customise its primary key, its foreign key, its type column and its scope,
17
+ # and "the column is called company_id" does not mean it holds a Company's
18
+ # primary key. Anything that makes the mapping less than certain falls back,
19
+ # because a wrong mapping here would report healthy accounts as broken and,
20
+ # in reconciliation, could drive a destructive repair.
21
+ #
22
+ # Records are always looked up +unscoped+. A default_scope says what the host
23
+ # application wants to list, not whether a row exists: a soft-deleted or
24
+ # archived record still owns its attachment rows, and those files are still
25
+ # stored. Honouring the scope here made healthy charges look unexpected, let
26
+ # budgeted reconciliation delete them, and made backfill skip them. Only the
27
+ # attached record is unscoped -- its payer is still resolved through the
28
+ # declared association, default scope and all, and the SQL join honours that
29
+ # too: a foreign key only counts when it names a payer the association could
30
+ # load.
31
+ class OwnerResolver # :nodoc:
32
+ def initialize(result:, record_findings: true)
33
+ @result = result
34
+ @record_findings = record_findings
35
+ @fast_paths = {}
36
+ @managed_subclasses = {}
37
+ @unresolvable_types = {}
38
+ @reported_types = Set.new
39
+ end
40
+
41
+ # Every record_type in the data that is, or contains, quota-managed models.
42
+ # => { record_type => klass }
43
+ def managed_record_types
44
+ ActiveStorage::Attachment.distinct.pluck(:record_type).each_with_object({}) do |type, map|
45
+ klass = resolve_record_type(type)
46
+ map[type] = klass if klass
47
+ end
48
+ end
49
+
50
+ # => { [owner_type, owner_id, blob_id] => attachment_count }
51
+ #
52
+ # Stops short of quota accounts on purpose: backfill runs before most
53
+ # accounts exist and needs owner identities in order to create them.
54
+ def owner_pairs_for_blobs(blob_ids)
55
+ pairs = Hash.new(0)
56
+
57
+ rows_by_type(blob_ids).each do |record_type, rows|
58
+ klass = resolve_record_type(record_type)
59
+ next if klass.nil?
60
+
61
+ owner_keys = owner_keys_for(klass, rows.map { |row| row[:record_id] }.uniq)
62
+
63
+ rows.each do |row|
64
+ owner_key = owner_keys[row[:record_id]]
65
+ next if owner_key.nil?
66
+
67
+ pairs[[ owner_key.first, owner_key.last, row[:blob_id].to_s ]] += 1
68
+ end
69
+ end
70
+
71
+ pairs
72
+ end
73
+
74
+ # => { [quota_account_id, blob_id] => attachment_count }
75
+ #
76
+ # Only counts attachments whose owner is quota-managed and already has an
77
+ # account; an owner without one is reported, never created.
78
+ def pairs_for_blobs(blob_ids)
79
+ owner_pairs = owner_pairs_for_blobs(blob_ids)
80
+ return {} if owner_pairs.empty?
81
+
82
+ accounts = accounts_by_owner(owner_pairs.keys.map { |type, id, _blob| [ type, id ] }.uniq)
83
+
84
+ owner_pairs.each_with_object(Hash.new(0)) do |((owner_type, owner_id, blob_id), count), map|
85
+ account_id = accounts[[ owner_type, owner_id ]]
86
+
87
+ if account_id.nil?
88
+ report_missing_account(owner_type, owner_id)
89
+ next
90
+ end
91
+
92
+ map[[ account_id, blob_id ]] += count
93
+ end
94
+ end
95
+
96
+ # Records of +klass+ whose declared payer is +owner+. Uses the SQL join when
97
+ # the reflection qualifies, and resolves in bounded batches otherwise.
98
+ def record_ids_for_owner(klass, owner)
99
+ fast_path = fast_path_for(klass)
100
+
101
+ if fast_path && fast_path[:owner_type] == owner.class.polymorphic_name
102
+ klass.unscoped.where(fast_path[:foreign_key] => owner.id).pluck(klass.primary_key)
103
+ else
104
+ slow_record_ids_for_owner(klass, owner)
105
+ end
106
+ end
107
+
108
+ private
109
+ attr_reader :result
110
+
111
+ def record_findings?
112
+ @record_findings
113
+ end
114
+
115
+ def rows_by_type(blob_ids)
116
+ ActiveStorage::Attachment
117
+ .where(blob_id: blob_ids)
118
+ .pluck(:record_type, :record_id, :blob_id)
119
+ .group_by(&:first)
120
+ .transform_values do |rows|
121
+ rows.map { |(_type, record_id, blob_id)| { record_id: record_id, blob_id: blob_id } }
122
+ end
123
+ end
124
+
125
+ # Keeps rows whose foreign key names a payer that record.<association>
126
+ # would actually return: the payer model's own scope applies, so a payer
127
+ # it hides -- or one deleted outright, leaving the key dangling -- resolves
128
+ # to nothing, exactly as it does when records are loaded. The payer is
129
+ # never looked up unscoped.
130
+ def with_available_payer(scope, fast_path)
131
+ payer = fast_path[:owner_class]
132
+ scope.where(fast_path[:foreign_key] => payer.select(payer.primary_key))
133
+ end
134
+
135
+ # Rails stores polymorphic_name, so safe_constantize is the right inverse.
136
+ # A type that no longer resolves is a finding, not a crash -- renamed and
137
+ # deleted models are exactly what an audit exists to find.
138
+ def resolve_record_type(record_type)
139
+ klass = record_type.safe_constantize
140
+
141
+ if klass.nil?
142
+ report_once(record_type) do
143
+ result.record(:unresolvable_record_type, record_type: record_type)
144
+ end
145
+ return nil
146
+ end
147
+
148
+ return klass if quota_managed?(klass)
149
+
150
+ # The base class declares no payer, but single table inheritance means
151
+ # record_type holds the base name while the rows may belong to
152
+ # subclasses that do. Asking the data which subclasses exist avoids both
153
+ # eager-loading the host application and silently dropping their
154
+ # attachments on the floor.
155
+ return klass if managed_subclasses?(klass)
156
+
157
+ # Framework-owned records such as a blob's preview_image live here. Not
158
+ # declaring a payer is the normal case, not a defect.
159
+ result.ignore_record_type(record_type)
160
+ nil
161
+ end
162
+
163
+ def managed_subclasses?(klass)
164
+ @managed_subclasses.fetch(klass.name) do
165
+ @managed_subclasses[klass.name] = compute_managed_subclasses(klass)
166
+ end
167
+ end
168
+
169
+ def compute_managed_subclasses(klass)
170
+ return false unless sti_enabled?(klass)
171
+
172
+ # Loaded subclasses answer for free; otherwise ask the table.
173
+ return true if klass.descendants.any? { |subclass| quota_managed?(subclass) }
174
+
175
+ subclass_names(klass).any? do |name|
176
+ subclass = name.safe_constantize
177
+
178
+ if subclass.nil?
179
+ report_once("sti:#{name}") do
180
+ result.record(:unresolvable_record_type, record_type: name)
181
+ end
182
+ next false
183
+ end
184
+
185
+ quota_managed?(subclass)
186
+ end
187
+ end
188
+
189
+ def subclass_names(klass)
190
+ klass.unscoped.distinct.pluck(klass.inheritance_column).compact
191
+ rescue StandardError
192
+ []
193
+ end
194
+
195
+ def sti_enabled?(klass)
196
+ klass.respond_to?(:inheritance_column) &&
197
+ klass.respond_to?(:column_names) &&
198
+ klass.column_names.include?(klass.inheritance_column)
199
+ rescue StandardError
200
+ false
201
+ end
202
+
203
+ def quota_managed?(klass)
204
+ klass.respond_to?(:storage_quota_owner_source) &&
205
+ klass.storage_quota_owner_source.present?
206
+ end
207
+
208
+ def report_once(key)
209
+ return unless record_findings?
210
+ return unless @reported_types.add?(key)
211
+
212
+ yield
213
+ end
214
+
215
+ def report_missing_account(owner_type, owner_id)
216
+ return unless record_findings?
217
+
218
+ report_once("account:#{owner_type}:#{owner_id}") do
219
+ result.record(:missing_quota_account, owner_type: owner_type, owner_id: owner_id)
220
+ end
221
+ end
222
+
223
+ # => { record_id => [owner_type, owner_id_string] }
224
+ def owner_keys_for(klass, record_ids)
225
+ fast_path = fast_path_for(klass)
226
+
227
+ if fast_path
228
+ resolve_by_join(klass, fast_path, record_ids)
229
+ else
230
+ resolve_by_loading(klass, record_ids)
231
+ end
232
+ end
233
+
234
+ def resolve_by_join(klass, fast_path, record_ids)
235
+ with_available_payer(klass.unscoped.where(klass.primary_key => record_ids), fast_path)
236
+ .pluck(klass.primary_key, fast_path[:foreign_key])
237
+ .each_with_object({}) do |(record_id, owner_id), map|
238
+ next if owner_id.nil? # nil owners are reported below
239
+
240
+ map[record_id] = [ fast_path[:owner_type], owner_id.to_s ]
241
+ end
242
+ .tap { |map| report_unowned(klass, record_ids - map.keys) }
243
+ end
244
+
245
+ # Loading through the base class instantiates each record as its real
246
+ # class, so an STI subclass that declares a different payer resolves
247
+ # through *its own* source. Reading the source off the base class here
248
+ # would silently attribute every subclass to the base's owner.
249
+ def resolve_by_loading(klass, record_ids)
250
+ map = {}
251
+
252
+ loadable_scope(klass, record_ids).find_each(batch_size: 500) do |record|
253
+ source = source_for(record)
254
+ # An STI subclass with no payer, under a base that has one. Nothing to
255
+ # charge, nothing wrong.
256
+ next if source.nil?
257
+
258
+ owner = source.resolve_safely(record)
259
+
260
+ if owner.nil?
261
+ report_unresolved_owner(record, source)
262
+ next
263
+ end
264
+
265
+ map[record.public_send(klass.primary_key)] =
266
+ [ owner.class.polymorphic_name, owner.id.to_s ]
267
+ end
268
+
269
+ map
270
+ end
271
+
272
+ def slow_record_ids_for_owner(klass, owner)
273
+ ids = []
274
+
275
+ loadable_scope(klass, nil).find_each(batch_size: 500) do |record|
276
+ source = source_for(record)
277
+ next if source.nil?
278
+
279
+ ids << record.public_send(klass.primary_key) if source.resolve_safely(record) == owner
280
+ end
281
+
282
+ ids
283
+ end
284
+
285
+ # A row whose STI type no longer constantizes raises SubclassNotFound on
286
+ # instantiation, which would abort a whole run over one bad row. Excluding
287
+ # those rows explicitly keeps things going and reports them.
288
+ #
289
+ # A nil record_ids means every record of this class.
290
+ def loadable_scope(klass, record_ids)
291
+ scope = record_ids.nil? ? klass.unscoped : klass.unscoped.where(klass.primary_key => record_ids)
292
+ unresolvable = unresolvable_types(klass)
293
+ return scope if unresolvable.empty?
294
+
295
+ scope.where.not(klass.inheritance_column => unresolvable)
296
+ end
297
+
298
+ def unresolvable_types(klass)
299
+ return [] unless sti_enabled?(klass)
300
+
301
+ @unresolvable_types.fetch(klass.name) do
302
+ names = subclass_names(klass).reject { |name| name.safe_constantize }
303
+ names.each do |name|
304
+ report_once("sti:#{name}") do
305
+ result.record(:unresolvable_record_type, record_type: name)
306
+ end
307
+ end
308
+
309
+ @unresolvable_types[klass.name] = names
310
+ end
311
+ end
312
+
313
+ def source_for(record)
314
+ return nil unless record.class.respond_to?(:storage_quota_owner_source)
315
+
316
+ record.class.storage_quota_owner_source
317
+ end
318
+
319
+ # A record whose declared owner is nil. On the join path this is a null
320
+ # foreign key; on the loading path resolve_safely returned nothing.
321
+ def report_unowned(klass, missing_ids)
322
+ return unless record_findings?
323
+
324
+ missing_ids.each do |record_id|
325
+ result.record(:missing_quota_owner,
326
+ record_type: klass.polymorphic_name, record_id: record_id.to_s,
327
+ owner_source: klass.storage_quota_owner_source.inspect)
328
+ end
329
+ end
330
+
331
+ def report_unresolved_owner(record, source)
332
+ return unless record_findings?
333
+
334
+ category = raw_owner_present?(record, source) ? :invalid_quota_owner : :missing_quota_owner
335
+
336
+ result.record(category,
337
+ record_type: record.class.polymorphic_name, record_id: record.id.to_s,
338
+ owner_source: source.inspect)
339
+ end
340
+
341
+ # resolve_safely collapses "no owner" and "owner is not a quota owner"
342
+ # into nil. Asking again, without the quota check, separates them.
343
+ def raw_owner_present?(record, source)
344
+ raw = source.source
345
+ value = raw.is_a?(Symbol) ? record.public_send(raw) : raw.call(record)
346
+ !value.nil?
347
+ rescue StandardError
348
+ false
349
+ end
350
+
351
+ def accounts_by_owner(owner_keys)
352
+ scope = ActiveStorageQuota::Account.where(scope_name: ActiveStorageQuota::DEFAULT_SCOPE)
353
+ conditions = owner_keys.group_by(&:first).map do |owner_type, keys|
354
+ scope.where(owner_type: owner_type, owner_id: keys.map(&:last))
355
+ end
356
+ return {} if conditions.empty?
357
+
358
+ conditions
359
+ .flat_map { |relation| relation.pluck(:owner_type, :owner_id, :id) }
360
+ .each_with_object({}) { |(type, id, account_id), map| map[[ type, id ]] = account_id }
361
+ end
362
+
363
+ # Nil unless a SQL join is *certainly* equivalent to resolving the declared
364
+ # source in Ruby.
365
+ def fast_path_for(klass)
366
+ @fast_paths.fetch(klass.name) { @fast_paths[klass.name] = compute_fast_path(klass) }
367
+ end
368
+
369
+ def compute_fast_path(klass)
370
+ return nil unless quota_managed?(klass)
371
+
372
+ source = klass.storage_quota_owner_source.source
373
+ return nil unless source.is_a?(Symbol)
374
+
375
+ # Single table inheritance: record_type holds the base class name, so the
376
+ # rows of one type may belong to subclasses that declare different
377
+ # payers. Establishing otherwise would mean loading every subclass,
378
+ # which this deliberately does not do.
379
+ return nil if klass.column_names.include?(klass.inheritance_column)
380
+
381
+ reflection = klass.reflect_on_association(source)
382
+ return nil unless reflection&.macro == :belongs_to
383
+ return nil if reflection.polymorphic?
384
+ return nil if reflection.scope
385
+
386
+ owner_klass = reflection.klass
387
+ return nil unless owner_klass.respond_to?(:storage_quota_definition)
388
+
389
+ # belongs_to :company, primary_key: :external_id means the foreign key
390
+ # holds an external_id, not a Company id. Joining on it would key the
391
+ # quota account to the wrong value.
392
+ return nil unless reflection.association_primary_key.to_s == owner_klass.primary_key.to_s
393
+
394
+ { foreign_key: reflection.foreign_key.to_s, owner_type: owner_klass.polymorphic_name,
395
+ owner_class: owner_klass }
396
+ rescue NameError
397
+ # class_name points at a constant that no longer exists. Fall back rather
398
+ # than guess; the ledger audit will report the attachments.
399
+ nil
400
+ end
401
+ end
402
+ end