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,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveStorageQuota
|
|
4
|
+
# Resolves the quota owner declared by +storage_quota_owner+.
|
|
5
|
+
#
|
|
6
|
+
# storage_quota_owner :company # Symbol: association or method
|
|
7
|
+
# storage_quota_owner ->(record) { ... } # callable taking the record
|
|
8
|
+
# storage_quota_owner -> { company } # callable evaluated on the record
|
|
9
|
+
#
|
|
10
|
+
# A Symbol naming a +belongs_to+ is worth preferring: it is the form a future
|
|
11
|
+
# set-based backfill can express as a SQL join, where a callable can only be
|
|
12
|
+
# resolved one record at a time.
|
|
13
|
+
class OwnerSource
|
|
14
|
+
SUPPORTED_ARITIES = [ 0, 1 ].freeze
|
|
15
|
+
|
|
16
|
+
attr_reader :source
|
|
17
|
+
|
|
18
|
+
def initialize(source)
|
|
19
|
+
@source = source
|
|
20
|
+
validate_declaration!
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The quota owner for +record+.
|
|
24
|
+
#
|
|
25
|
+
# Raises MissingQuotaOwner when it resolves to nil, and ConfigurationError
|
|
26
|
+
# when the result is not a quota owner.
|
|
27
|
+
def resolve(record)
|
|
28
|
+
owner = call_source(record)
|
|
29
|
+
|
|
30
|
+
raise MissingQuotaOwner.new(record: record, owner_source: source) if owner.nil?
|
|
31
|
+
|
|
32
|
+
unless quota_owner?(owner)
|
|
33
|
+
raise ConfigurationError,
|
|
34
|
+
"#{record.class.name} declares storage_quota_owner #{source.inspect}, " \
|
|
35
|
+
"which returned #{owner.class.name}; that model does not declare " \
|
|
36
|
+
"has_storage_quota"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
owner
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The quota owner for +record+, or nil for any reason at all.
|
|
43
|
+
#
|
|
44
|
+
# Used where accounting must not raise: destruction bookkeeping, which fails
|
|
45
|
+
# open, and auditing, which has to survive malformed historical data in
|
|
46
|
+
# order to report it.
|
|
47
|
+
def resolve_safely(record)
|
|
48
|
+
owner = call_source(record)
|
|
49
|
+
owner if quota_owner?(owner)
|
|
50
|
+
rescue StandardError
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def inspect
|
|
55
|
+
source.inspect
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
def call_source(record)
|
|
60
|
+
case source
|
|
61
|
+
when Symbol then record.public_send(source)
|
|
62
|
+
else
|
|
63
|
+
source.arity.zero? ? record.instance_exec(&source) : source.call(record)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def quota_owner?(owner)
|
|
68
|
+
!owner.nil? && owner.respond_to?(:storage_quota_account)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def validate_declaration!
|
|
72
|
+
return if source.is_a?(Symbol)
|
|
73
|
+
|
|
74
|
+
unless source.respond_to?(:call)
|
|
75
|
+
raise ConfigurationError,
|
|
76
|
+
"storage_quota_owner must be a Symbol naming a method on the " \
|
|
77
|
+
"record, or a callable; got #{source.inspect} (#{source.class})"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
return if SUPPORTED_ARITIES.include?(source.arity)
|
|
81
|
+
|
|
82
|
+
raise ConfigurationError,
|
|
83
|
+
"storage_quota_owner: a callable must take no arguments " \
|
|
84
|
+
"(evaluated against the record) or one argument (the record); " \
|
|
85
|
+
"got arity #{source.arity}"
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveStorageQuota
|
|
4
|
+
module Reconciliation
|
|
5
|
+
# How much destructive repair one reconciliation invocation is permitted.
|
|
6
|
+
#
|
|
7
|
+
# There is no default budget on purpose. Deleting a charge lowers a
|
|
8
|
+
# customer's recorded usage, and an over-generous default would let a bug in
|
|
9
|
+
# owner resolution silently hand out storage. With no limits supplied,
|
|
10
|
+
# unexpected charges are reported and left alone.
|
|
11
|
+
#
|
|
12
|
+
# The asymmetry with additive repair is deliberate: over-charging announces
|
|
13
|
+
# itself when the customer hits their quota and is trivially reversible,
|
|
14
|
+
# while under-charging is silent and may never be noticed.
|
|
15
|
+
class Budget
|
|
16
|
+
attr_reader :max_charge_deletions, :max_removed_fraction
|
|
17
|
+
|
|
18
|
+
def initialize(max_charge_deletions: nil, max_removed_fraction: nil)
|
|
19
|
+
@max_charge_deletions = validate_deletions(max_charge_deletions)
|
|
20
|
+
@max_removed_fraction = validate_fraction(max_removed_fraction)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Destructive repair happens only when explicitly budgeted.
|
|
24
|
+
def destructive?
|
|
25
|
+
!max_charge_deletions.nil? || !max_removed_fraction.nil?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Whether removing this much would break the budget. Used twice: once on
|
|
29
|
+
# the whole account before any deletion starts, and again before each
|
|
30
|
+
# individual deletion, because the account is live and moves underneath us.
|
|
31
|
+
def exceeded?(deletions:, bytes_removed:, used_bytes:)
|
|
32
|
+
return true if max_charge_deletions && deletions > max_charge_deletions
|
|
33
|
+
|
|
34
|
+
if max_removed_fraction && used_bytes.to_i.positive?
|
|
35
|
+
return true if bytes_removed > used_bytes * max_removed_fraction
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
false
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def to_h
|
|
42
|
+
{ max_charge_deletions: max_charge_deletions, max_removed_fraction: max_removed_fraction }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
def validate_deletions(value)
|
|
47
|
+
return nil if value.nil?
|
|
48
|
+
|
|
49
|
+
unless value.is_a?(Integer) && value.positive?
|
|
50
|
+
raise ArgumentError,
|
|
51
|
+
"max_charge_deletions must be a positive Integer, got #{value.inspect}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
value
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def validate_fraction(value)
|
|
58
|
+
return nil if value.nil?
|
|
59
|
+
|
|
60
|
+
unless value.is_a?(Numeric) && value > 0 && value <= 1
|
|
61
|
+
raise ArgumentError,
|
|
62
|
+
"max_removed_fraction must be a number greater than 0 and at most 1, " \
|
|
63
|
+
"got #{value.inspect}"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
value
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveStorageQuota
|
|
4
|
+
module Reconciliation
|
|
5
|
+
# Level 1: repairs Account#used_bytes from the charges it aggregates.
|
|
6
|
+
#
|
|
7
|
+
# Cheap and safe to run often. Touches no Active Storage table, so it works
|
|
8
|
+
# regardless of how Active Storage is configured or whether it is loaded.
|
|
9
|
+
#
|
|
10
|
+
# The account row is locked FOR UPDATE before the sum is taken, which is
|
|
11
|
+
# what stops this race:
|
|
12
|
+
#
|
|
13
|
+
# reconciler reads SUM = 100
|
|
14
|
+
# an upload commits, used_bytes += 20
|
|
15
|
+
# reconciler writes 100 <- the 20 would be lost
|
|
16
|
+
#
|
|
17
|
+
# Charge.record! and Charge.discard! both reach used_bytes through an UPDATE
|
|
18
|
+
# of that same row, so they block behind the lock and apply on top of the
|
|
19
|
+
# value written here rather than being overwritten by it.
|
|
20
|
+
#
|
|
21
|
+
# Locks only the account. Charges are read, never locked, so this can never
|
|
22
|
+
# deadlock against ledger reconciliation's charge-then-account order.
|
|
23
|
+
class CounterReconciler
|
|
24
|
+
def initialize(result:, dry_run:)
|
|
25
|
+
@result = result
|
|
26
|
+
@dry_run = dry_run
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def run(accounts)
|
|
30
|
+
accounts.each { |account| reconcile(account) }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
attr_reader :result
|
|
35
|
+
|
|
36
|
+
def reconcile(account)
|
|
37
|
+
result.increment(:accounts_checked)
|
|
38
|
+
|
|
39
|
+
if @dry_run
|
|
40
|
+
plan(account)
|
|
41
|
+
else
|
|
42
|
+
apply(account)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def plan(account)
|
|
47
|
+
stored = account.reload.used_bytes
|
|
48
|
+
expected = expected_used_bytes(account.id)
|
|
49
|
+
return if stored == expected
|
|
50
|
+
|
|
51
|
+
result.increment(:counters_corrected)
|
|
52
|
+
result.record(:planned_change, action: :correct_counter, quota_account_id: account.id,
|
|
53
|
+
from: stored, to: expected)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def apply(account)
|
|
57
|
+
Account.transaction(requires_new: true) do
|
|
58
|
+
locked = Account.lock.find_by(id: account.id)
|
|
59
|
+
next if locked.nil?
|
|
60
|
+
|
|
61
|
+
expected = expected_used_bytes(locked.id)
|
|
62
|
+
next if locked.used_bytes == expected
|
|
63
|
+
|
|
64
|
+
Account.where(id: locked.id)
|
|
65
|
+
.update_all([ "used_bytes = ?, updated_at = ?", expected, Time.current ])
|
|
66
|
+
|
|
67
|
+
result.increment(:counters_corrected)
|
|
68
|
+
result.record(:counter_corrected, quota_account_id: locked.id,
|
|
69
|
+
from: locked.used_bytes, to: expected)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def expected_used_bytes(account_id)
|
|
74
|
+
Charge.where(quota_account_id: account_id).sum(:byte_size)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveStorageQuota
|
|
4
|
+
module Reconciliation
|
|
5
|
+
# Level 2: repairs charges from live Active Storage ownership.
|
|
6
|
+
#
|
|
7
|
+
# The unit of work is one (account, blob) pair in its own short transaction,
|
|
8
|
+
# which is also the unit of retry and of failure. A pair that raises does not
|
|
9
|
+
# undo pairs already repaired; partial progress is reported rather than
|
|
10
|
+
# hidden.
|
|
11
|
+
#
|
|
12
|
+
# Lock order is charge first, then account -- the same order normal
|
|
13
|
+
# accounting uses -- so the two can run concurrently without deadlocking.
|
|
14
|
+
class LedgerReconciler
|
|
15
|
+
BATCH_SIZE = 500
|
|
16
|
+
|
|
17
|
+
def initialize(result:, budget:, dry_run:)
|
|
18
|
+
@result = result
|
|
19
|
+
@budget = budget
|
|
20
|
+
@dry_run = dry_run
|
|
21
|
+
@audit_result = Audit::Result.new
|
|
22
|
+
@resolver = ActiveStorageQuota::OwnerResolver.new(result: @audit_result, record_findings: true)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def run(owner)
|
|
26
|
+
account = Account.find_or_create_for!(owner) unless @dry_run
|
|
27
|
+
account ||= owner.storage_quota_account
|
|
28
|
+
|
|
29
|
+
result.increment(:accounts_checked)
|
|
30
|
+
|
|
31
|
+
blob_ids = candidate_blob_ids(owner, account)
|
|
32
|
+
return if blob_ids.empty?
|
|
33
|
+
|
|
34
|
+
account ||= owner.storage_quota_account
|
|
35
|
+
if account.nil?
|
|
36
|
+
# Dry run against an owner with no account yet: report what applying
|
|
37
|
+
# would create rather than creating it.
|
|
38
|
+
result.record(:planned_change, action: :create_quota_account,
|
|
39
|
+
owner_type: owner.class.polymorphic_name,
|
|
40
|
+
owner_id: owner.id.to_s)
|
|
41
|
+
return
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
repair_pairs(account, blob_ids)
|
|
45
|
+
carry_report_only_findings
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
attr_reader :result, :budget
|
|
50
|
+
|
|
51
|
+
def repair_pairs(account, blob_ids)
|
|
52
|
+
# Preflight is read-only, so a dry run runs exactly the same check and
|
|
53
|
+
# predicts the same breaker trips.
|
|
54
|
+
removable = preflight(account, blob_ids)
|
|
55
|
+
deletions = 0
|
|
56
|
+
bytes_removed = 0
|
|
57
|
+
# Bug found in testing: measuring the removed fraction against a
|
|
58
|
+
# used_bytes that our own deletions keep shrinking makes the
|
|
59
|
+
# denominator collapse and trips the breaker spuriously. The budget is
|
|
60
|
+
# a fraction of what the account held when this run started.
|
|
61
|
+
baseline_used_bytes = account.reload.used_bytes
|
|
62
|
+
|
|
63
|
+
blob_ids.each_slice(BATCH_SIZE) do |slice|
|
|
64
|
+
expected = expected_counts(account, slice)
|
|
65
|
+
sizes = blob_sizes(slice)
|
|
66
|
+
|
|
67
|
+
slice.each do |blob_id|
|
|
68
|
+
result.increment(:pairs_checked)
|
|
69
|
+
count = expected.fetch(blob_id, 0)
|
|
70
|
+
|
|
71
|
+
within_budget = within_budget?(baseline_used_bytes, deletions, bytes_removed,
|
|
72
|
+
sizes[blob_id])
|
|
73
|
+
|
|
74
|
+
outcome = repair_pair(account, blob_id, sizes[blob_id], count, removable && within_budget)
|
|
75
|
+
|
|
76
|
+
case outcome
|
|
77
|
+
when :removed
|
|
78
|
+
deletions += 1
|
|
79
|
+
bytes_removed += sizes[blob_id].to_i
|
|
80
|
+
when :deletion_skipped
|
|
81
|
+
# The breaker trips only when a real removal was refused because
|
|
82
|
+
# of the budget -- never for a pair that needed no removal.
|
|
83
|
+
trip_apply_breaker!(account, deletions, bytes_removed) if removable && !within_budget
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Would removing this pair keep the run inside its budget? Asked for
|
|
90
|
+
# every pair, not only planned candidates, because the account is live:
|
|
91
|
+
# a concurrent detach can turn a healthy pair into a removal between the
|
|
92
|
+
# plan and the locked recount. Deliberately pure -- whether the breaker
|
|
93
|
+
# trips depends on what the pair turned out to need.
|
|
94
|
+
def within_budget?(baseline_used_bytes, deletions, bytes_removed, byte_size)
|
|
95
|
+
return true unless budget.destructive?
|
|
96
|
+
|
|
97
|
+
!budget.exceeded?(deletions: deletions + 1,
|
|
98
|
+
bytes_removed: bytes_removed + byte_size.to_i,
|
|
99
|
+
used_bytes: baseline_used_bytes)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def trip_apply_breaker!(account, deletions, bytes_removed)
|
|
103
|
+
result.trip_circuit_breaker!(stage: :apply, quota_account_id: account.id,
|
|
104
|
+
deletions: deletions, bytes_removed: bytes_removed)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Before touching anything, price the whole account's destructive work.
|
|
108
|
+
# Over budget means we do not start: skipping every repair for that
|
|
109
|
+
# account, not only the deletions, because a tripped breaker means this
|
|
110
|
+
# account's picture is not trusted.
|
|
111
|
+
def preflight(account, blob_ids)
|
|
112
|
+
return false unless budget.destructive?
|
|
113
|
+
|
|
114
|
+
planned_deletions = 0
|
|
115
|
+
planned_bytes = 0
|
|
116
|
+
|
|
117
|
+
blob_ids.each_slice(BATCH_SIZE) do |slice|
|
|
118
|
+
expected = expected_counts(account, slice)
|
|
119
|
+
sizes = blob_sizes(slice)
|
|
120
|
+
charges = charge_sizes(account, slice)
|
|
121
|
+
|
|
122
|
+
slice.each do |blob_id|
|
|
123
|
+
next unless charges.key?(blob_id)
|
|
124
|
+
next unless expected.fetch(blob_id, 0).zero?
|
|
125
|
+
|
|
126
|
+
planned_deletions += 1
|
|
127
|
+
planned_bytes += charges[blob_id].to_i
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
return true unless budget.exceeded?(deletions: planned_deletions,
|
|
132
|
+
bytes_removed: planned_bytes,
|
|
133
|
+
used_bytes: account.used_bytes)
|
|
134
|
+
|
|
135
|
+
result.trip_circuit_breaker!(stage: :preflight, quota_account_id: account.id,
|
|
136
|
+
planned_deletions: planned_deletions,
|
|
137
|
+
planned_bytes_removed: planned_bytes)
|
|
138
|
+
false
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def repair_pair(account, blob_id, byte_size, expected_count, allow_deletion)
|
|
142
|
+
if @dry_run
|
|
143
|
+
plan_pair(account, blob_id, byte_size, expected_count, allow_deletion)
|
|
144
|
+
else
|
|
145
|
+
apply_pair(account, blob_id, byte_size, allow_deletion)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def plan_pair(account, blob_id, byte_size, expected_count, allow_deletion)
|
|
150
|
+
outcome = Charge.plan_pair(quota_account_id: account.id, blob_id: blob_id,
|
|
151
|
+
allow_deletion: allow_deletion, expected_count: expected_count)
|
|
152
|
+
tally(outcome, account, blob_id, byte_size, planned: true)
|
|
153
|
+
outcome
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def apply_pair(account, blob_id, byte_size, allow_deletion)
|
|
157
|
+
outcome = Charge.reconcile_pair!(
|
|
158
|
+
quota_account_id: account.id, blob_id: blob_id,
|
|
159
|
+
blob_byte_size: byte_size, allow_deletion: allow_deletion
|
|
160
|
+
) { live_attachment_count(account, blob_id) }
|
|
161
|
+
|
|
162
|
+
tally(outcome, account, blob_id, byte_size, planned: false)
|
|
163
|
+
outcome
|
|
164
|
+
rescue StandardError => e
|
|
165
|
+
# Reconciliation is explicit maintenance, not a fail-open callback, so
|
|
166
|
+
# failures stay visible. One bad pair does not abandon the rest.
|
|
167
|
+
result.record(:errors, quota_account_id: account.id, blob_id: blob_id,
|
|
168
|
+
exception_class: e.class.name, exception_message: e.message)
|
|
169
|
+
:error
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def tally(outcome, account, blob_id, byte_size, planned:)
|
|
173
|
+
case outcome
|
|
174
|
+
when :created
|
|
175
|
+
result.increment(:charges_created).increment(:bytes_added, by: byte_size.to_i)
|
|
176
|
+
when :removed
|
|
177
|
+
result.increment(:charges_removed).increment(:bytes_removed, by: byte_size.to_i)
|
|
178
|
+
when :count_corrected
|
|
179
|
+
result.increment(:attachment_counts_corrected)
|
|
180
|
+
when :deletion_skipped
|
|
181
|
+
result.record(:skipped, reason: :destructive_repair_not_permitted,
|
|
182
|
+
quota_account_id: account.id, blob_id: blob_id)
|
|
183
|
+
return
|
|
184
|
+
else
|
|
185
|
+
return
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
return unless planned
|
|
189
|
+
|
|
190
|
+
result.record(:planned_change, action: outcome, quota_account_id: account.id,
|
|
191
|
+
blob_id: blob_id, byte_size: byte_size)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# The authoritative count, recomputed inside the repair transaction.
|
|
195
|
+
def live_attachment_count(account, blob_id)
|
|
196
|
+
@resolver.pairs_for_blobs([ blob_id ]).fetch([ account.id, blob_id.to_s ], 0)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def expected_counts(account, blob_ids)
|
|
200
|
+
@resolver.pairs_for_blobs(blob_ids).each_with_object(Hash.new(0)) do |((acct, blob), count), map|
|
|
201
|
+
map[blob] = count if acct == account.id
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def candidate_blob_ids(owner, account)
|
|
206
|
+
from_charges = account ? Charge.where(quota_account_id: account.id).pluck(:blob_id) : []
|
|
207
|
+
|
|
208
|
+
(from_charges.map(&:to_s) + owner_attachment_blob_ids(owner)).uniq.sort
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Blobs attached to records this owner pays for. Where the payer is a
|
|
212
|
+
# plain belongs_to this is an indexed lookup; otherwise each candidate
|
|
213
|
+
# record's declared source is resolved in Ruby.
|
|
214
|
+
def owner_attachment_blob_ids(owner)
|
|
215
|
+
@resolver.managed_record_types.flat_map do |record_type, klass|
|
|
216
|
+
record_ids = @resolver.record_ids_for_owner(klass, owner)
|
|
217
|
+
next [] if record_ids.empty?
|
|
218
|
+
|
|
219
|
+
ActiveStorage::Attachment
|
|
220
|
+
.where(record_type: record_type, record_id: record_ids)
|
|
221
|
+
.pluck(:blob_id).map(&:to_s)
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def blob_sizes(blob_ids)
|
|
226
|
+
ActiveStorage::Blob.where(id: blob_ids).pluck(:id, :byte_size)
|
|
227
|
+
.each_with_object({}) { |(id, size), map| map[id.to_s] = size }
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def charge_sizes(account, blob_ids)
|
|
231
|
+
Charge.where(quota_account_id: account.id, blob_id: blob_ids)
|
|
232
|
+
.pluck(:blob_id, :byte_size)
|
|
233
|
+
.each_with_object({}) { |(blob_id, size), map| map[blob_id.to_s] = size }
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Findings the resolver noticed that reconciliation deliberately does
|
|
237
|
+
# not repair. They are surfaced, never acted on.
|
|
238
|
+
def carry_report_only_findings
|
|
239
|
+
%i[missing_quota_owner invalid_quota_owner unresolvable_record_type].each do |category|
|
|
240
|
+
total = @audit_result.count(category)
|
|
241
|
+
next if total.zero?
|
|
242
|
+
|
|
243
|
+
@audit_result.sample(category).each do |finding|
|
|
244
|
+
result.record(:report_only, category: category, **finding.details)
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module ActiveStorageQuota
|
|
6
|
+
module Reconciliation
|
|
7
|
+
# What a reconciliation run did, or would have done.
|
|
8
|
+
#
|
|
9
|
+
# Bounded like Audit::Result: exact totals, capped samples, scalars only.
|
|
10
|
+
# Partial progress is deliberately visible -- per-pair transactions mean a
|
|
11
|
+
# failure part-way through leaves earlier repairs in place, and pretending
|
|
12
|
+
# otherwise would be a lie about the guarantee.
|
|
13
|
+
class Result
|
|
14
|
+
DEFAULT_SAMPLE_LIMIT = 100
|
|
15
|
+
|
|
16
|
+
COUNTERS = %i[
|
|
17
|
+
accounts_checked pairs_checked
|
|
18
|
+
charges_created charges_removed attachment_counts_corrected counters_corrected
|
|
19
|
+
bytes_added bytes_removed
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
22
|
+
attr_reader :sample_limit, :counts, :samples
|
|
23
|
+
|
|
24
|
+
def initialize(dry_run:, sample_limit: DEFAULT_SAMPLE_LIMIT)
|
|
25
|
+
@dry_run = dry_run
|
|
26
|
+
@sample_limit = sample_limit
|
|
27
|
+
@totals = Hash.new(0)
|
|
28
|
+
@counts = Hash.new(0)
|
|
29
|
+
@samples = Hash.new { |hash, key| hash[key] = [] }
|
|
30
|
+
@circuit_breaker_triggered = false
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def dry_run?
|
|
34
|
+
@dry_run
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def circuit_breaker_triggered?
|
|
38
|
+
@circuit_breaker_triggered
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
COUNTERS.each do |name|
|
|
42
|
+
define_method(name) { @totals[name] }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def increment(counter, by: 1)
|
|
46
|
+
@totals[counter] += by
|
|
47
|
+
self
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# A planned change (dry run) or a category needing attention: skipped,
|
|
51
|
+
# unsupported, errors, report-only findings.
|
|
52
|
+
def record(category, **details)
|
|
53
|
+
category = category.to_sym
|
|
54
|
+
@counts[category] += 1
|
|
55
|
+
@samples[category] << details if @samples[category].size < sample_limit
|
|
56
|
+
|
|
57
|
+
self
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def trip_circuit_breaker!(**details)
|
|
61
|
+
@circuit_breaker_triggered = true
|
|
62
|
+
record(:circuit_breaker_triggered, **details)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def count(category)
|
|
66
|
+
@counts[category.to_sym]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def sample(category)
|
|
70
|
+
@samples[category.to_sym]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def planned_changes
|
|
74
|
+
@samples[:planned_change]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def changed?
|
|
78
|
+
(charges_created + charges_removed + attachment_counts_corrected + counters_corrected).positive?
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def to_h
|
|
82
|
+
{
|
|
83
|
+
dry_run: dry_run?,
|
|
84
|
+
circuit_breaker_triggered: circuit_breaker_triggered?,
|
|
85
|
+
totals: COUNTERS.to_h { |name| [ name, @totals[name] ] },
|
|
86
|
+
categories: @counts.dup,
|
|
87
|
+
samples: @samples.transform_values(&:dup)
|
|
88
|
+
}
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def to_s
|
|
92
|
+
headline = dry_run? ? "reconciliation plan" : "reconciliation"
|
|
93
|
+
lines = [ "#{headline}: #{summary_line}" ]
|
|
94
|
+
@counts.sort_by { |_, total| -total }.each { |category, total| lines << " #{category}: #{total}" }
|
|
95
|
+
lines << " CIRCUIT BREAKER TRIPPED" if circuit_breaker_triggered?
|
|
96
|
+
lines.join("\n")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
def summary_line
|
|
101
|
+
"#{accounts_checked} account(s), #{pairs_checked} pair(s), " \
|
|
102
|
+
"+#{charges_created} charge(s), -#{charges_removed} charge(s), " \
|
|
103
|
+
"#{attachment_counts_corrected} count(s), #{counters_corrected} counter(s)"
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_storage_quota/reconciliation/budget"
|
|
4
|
+
require "active_storage_quota/reconciliation/result"
|
|
5
|
+
require "active_storage_quota/reconciliation/counter_reconciler"
|
|
6
|
+
require "active_storage_quota/reconciliation/ledger_reconciler"
|
|
7
|
+
|
|
8
|
+
module ActiveStorageQuota
|
|
9
|
+
# Repair operations built on what the audit reports.
|
|
10
|
+
#
|
|
11
|
+
# Two levels, kept separate because they have different costs and different
|
|
12
|
+
# risks:
|
|
13
|
+
#
|
|
14
|
+
# counters Account#used_bytes from charges. Cheap, safe, no Active Storage.
|
|
15
|
+
# ledger Charges from live Active Storage ownership. Expensive, and the
|
|
16
|
+
# only level that can destroy anything.
|
|
17
|
+
#
|
|
18
|
+
# Audit findings are candidates, never commands: every repair re-reads the
|
|
19
|
+
# current state under lock before it mutates anything, because the world moves
|
|
20
|
+
# between auditing and repairing.
|
|
21
|
+
module Reconciliation
|
|
22
|
+
class << self
|
|
23
|
+
def counters!(owner:, dry_run: false, sample_limit: Result::DEFAULT_SAMPLE_LIMIT)
|
|
24
|
+
result = Result.new(dry_run: dry_run, sample_limit: sample_limit)
|
|
25
|
+
CounterReconciler.new(result: result, dry_run: dry_run).run(accounts_for(owner))
|
|
26
|
+
result
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def ledger!(owner:, dry_run: false, sample_limit: Result::DEFAULT_SAMPLE_LIMIT, **limits)
|
|
30
|
+
result = Result.new(dry_run: dry_run, sample_limit: sample_limit)
|
|
31
|
+
run_ledger(owner, result, budget(limits), dry_run)
|
|
32
|
+
result
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Ledger first, then counters: counters are derived from charges, so
|
|
36
|
+
# repairing them before the ledger would just recompute a stale total.
|
|
37
|
+
def all!(owner:, dry_run: false, sample_limit: Result::DEFAULT_SAMPLE_LIMIT, **limits)
|
|
38
|
+
result = Result.new(dry_run: dry_run, sample_limit: sample_limit)
|
|
39
|
+
|
|
40
|
+
run_ledger(owner, result, budget(limits), dry_run)
|
|
41
|
+
CounterReconciler.new(result: result, dry_run: dry_run).run(accounts_for(owner))
|
|
42
|
+
|
|
43
|
+
result
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
def run_ledger(owner, result, budget, dry_run)
|
|
48
|
+
unless active_storage_available?
|
|
49
|
+
result.record(:unsupported, reason: "Active Storage is not loaded")
|
|
50
|
+
return
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
LedgerReconciler.new(result: result, budget: budget, dry_run: dry_run).run(owner)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def budget(limits)
|
|
57
|
+
Budget.new(
|
|
58
|
+
max_charge_deletions: limits[:max_charge_deletions],
|
|
59
|
+
max_removed_fraction: limits[:max_removed_fraction]
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def accounts_for(owner)
|
|
64
|
+
Account.where(owner: owner).order(:id)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def active_storage_available?
|
|
68
|
+
defined?(ActiveStorage::Attachment) && defined?(ActiveStorage::Blob)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_record"
|
|
4
|
+
|
|
5
|
+
module ActiveStorageQuota
|
|
6
|
+
# Abstract base for this gem's models.
|
|
7
|
+
#
|
|
8
|
+
# Deliberately inherits from ActiveRecord::Base rather than the host
|
|
9
|
+
# application's ApplicationRecord: a gem cannot assume that constant exists,
|
|
10
|
+
# and inheriting from it would drag application-wide concerns, default scopes
|
|
11
|
+
# and callbacks into quota accounting.
|
|
12
|
+
#
|
|
13
|
+
# Having a base of our own also gives applications a single place to point
|
|
14
|
+
# these tables at another database:
|
|
15
|
+
#
|
|
16
|
+
# ActiveStorageQuota::Record.connects_to database: { writing: :quota }
|
|
17
|
+
class Record < ActiveRecord::Base
|
|
18
|
+
self.abstract_class = true
|
|
19
|
+
end
|
|
20
|
+
end
|