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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +52 -0
- data/LICENSE.txt +21 -0
- data/README.md +404 -0
- data/app/controllers/active_storage_quota/direct_uploads_controller.rb +96 -0
- data/db/migrate/20260917000001_create_active_storage_quota_tables.rb +72 -0
- data/db/migrate/20260918000001_create_active_storage_quota_charges.rb +45 -0
- data/lib/active_storage_quota/account.rb +200 -0
- data/lib/active_storage_quota/attachable.rb +44 -0
- data/lib/active_storage_quota/attachment_accounting.rb +144 -0
- data/lib/active_storage_quota/audit/counter_audit.rb +62 -0
- data/lib/active_storage_quota/audit/finding.rb +17 -0
- data/lib/active_storage_quota/audit/ledger_audit.rb +164 -0
- data/lib/active_storage_quota/audit/result.rb +106 -0
- data/lib/active_storage_quota/audit.rb +75 -0
- data/lib/active_storage_quota/backfill/cursor.rb +64 -0
- data/lib/active_storage_quota/backfill/result.rb +146 -0
- data/lib/active_storage_quota/backfill.rb +310 -0
- data/lib/active_storage_quota/blob_accounting.rb +44 -0
- data/lib/active_storage_quota/charge.rb +379 -0
- data/lib/active_storage_quota/configuration.rb +37 -0
- data/lib/active_storage_quota/definition.rb +100 -0
- data/lib/active_storage_quota/engine.rb +28 -0
- data/lib/active_storage_quota/errors.rb +115 -0
- data/lib/active_storage_quota/owner.rb +154 -0
- data/lib/active_storage_quota/owner_resolver.rb +402 -0
- data/lib/active_storage_quota/owner_source.rb +88 -0
- data/lib/active_storage_quota/reconciliation/budget.rb +70 -0
- data/lib/active_storage_quota/reconciliation/counter_reconciler.rb +78 -0
- data/lib/active_storage_quota/reconciliation/ledger_reconciler.rb +250 -0
- data/lib/active_storage_quota/reconciliation/result.rb +107 -0
- data/lib/active_storage_quota/reconciliation.rb +72 -0
- data/lib/active_storage_quota/record.rb +20 -0
- data/lib/active_storage_quota/reservation.rb +297 -0
- data/lib/active_storage_quota/version.rb +5 -0
- data/lib/active_storage_quota.rb +207 -0
- data/lib/tasks/active_storage_quota.rake +11 -0
- data/lib/tasks/active_storage_quota_audit.rake +15 -0
- data/lib/tasks/active_storage_quota_backfill.rake +104 -0
- data/lib/tasks/active_storage_quota_reconcile.rake +100 -0
- 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
|