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,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