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,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module ActiveStorageQuota
6
+ module Audit
7
+ # What an audit found.
8
+ #
9
+ # Every category keeps a total count and a bounded sample, so auditing an
10
+ # installation with millions of divergent rows cannot itself become the
11
+ # problem. Nothing here is ever written back.
12
+ class Result
13
+ DEFAULT_SAMPLE_LIMIT = 100
14
+
15
+ attr_reader :sample_limit, :consistency, :counts, :samples,
16
+ :unsupported_checks, :ignored_record_types
17
+
18
+ # +consistency+ is :snapshot when every check ran as same-connection SQL,
19
+ # and :best_effort when values had to be compared across connections in
20
+ # Ruby, where concurrent traffic can shift between reads.
21
+ def initialize(sample_limit: DEFAULT_SAMPLE_LIMIT, consistency: :snapshot)
22
+ @sample_limit = sample_limit
23
+ @consistency = consistency
24
+ @counts = Hash.new(0)
25
+ @samples = Hash.new { |hash, key| hash[key] = [] }
26
+ @unsupported_checks = {}
27
+ @ignored_record_types = Set.new
28
+ end
29
+
30
+ def record(category, **details)
31
+ category = category.to_sym
32
+ @counts[category] += 1
33
+ @samples[category] << Finding.new(category: category, details: details) if
34
+ @samples[category].size < sample_limit
35
+
36
+ self
37
+ end
38
+
39
+ # A check that could not run here, for example comparing charges against
40
+ # blobs that live on another connection. The audit continues; only this
41
+ # check is reported as unavailable.
42
+ def unsupported!(category, reason:)
43
+ @unsupported_checks[category.to_sym] = reason
44
+ self
45
+ end
46
+
47
+ def ignore_record_type(record_type)
48
+ @ignored_record_types << record_type
49
+ self
50
+ end
51
+
52
+ def clean?
53
+ findings_count.zero?
54
+ end
55
+
56
+ def findings_count
57
+ counts.values.sum
58
+ end
59
+
60
+ # The bounded samples, flattened. Use +summary+ for the true totals.
61
+ def findings
62
+ samples.values.flatten
63
+ end
64
+
65
+ def sample(category)
66
+ samples[category.to_sym]
67
+ end
68
+
69
+ def count(category)
70
+ counts[category.to_sym]
71
+ end
72
+
73
+ # Every category's true total, even where the sample was truncated.
74
+ def summary
75
+ counts.dup
76
+ end
77
+
78
+ def truncated?(category)
79
+ count(category) > sample(category).size
80
+ end
81
+
82
+ def to_h
83
+ {
84
+ clean: clean?,
85
+ consistency: consistency,
86
+ findings_count: findings_count,
87
+ summary: summary,
88
+ samples: samples.transform_values { |list| list.map(&:to_h) },
89
+ unsupported_checks: unsupported_checks,
90
+ ignored_record_types: ignored_record_types.to_a.sort
91
+ }
92
+ end
93
+
94
+ def to_s
95
+ return "audit clean (#{consistency})" if clean? && unsupported_checks.empty?
96
+
97
+ lines = [ "audit found #{findings_count} issue(s) (#{consistency}):" ]
98
+ summary.sort_by { |_, total| -total }.each do |category, total|
99
+ lines << " #{category}: #{total}#{' (sample truncated)' if truncated?(category)}"
100
+ end
101
+ unsupported_checks.each { |category, reason| lines << " #{category}: unavailable (#{reason})" }
102
+ lines.join("\n")
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_storage_quota/audit/finding"
4
+ require "active_storage_quota/audit/result"
5
+ require "active_storage_quota/audit/counter_audit"
6
+ require "active_storage_quota/audit/ledger_audit"
7
+
8
+ module ActiveStorageQuota
9
+ # Read-only inspection of quota accounting.
10
+ #
11
+ # result = ActiveStorageQuota.audit
12
+ # result.clean? # => false
13
+ # puts result
14
+ #
15
+ # The audit performs no writes of any kind. It creates no quota accounts --
16
+ # reads never do -- repairs nothing, and deletes nothing. Everything it
17
+ # notices is reported for a human to decide about.
18
+ #
19
+ # Three layers of state are compared, and the audit keeps them distinct:
20
+ #
21
+ # ActiveStorage::Attachment ultimate truth for logical ownership
22
+ # ActiveStorageQuota::Charge canonical accounting ledger
23
+ # Account#used_bytes denormalized aggregate of charges
24
+ #
25
+ # so there are two audits internally: a counter audit (account against its
26
+ # charges) and a ledger audit (charges against attachments and blobs). The
27
+ # first can always run. The second needs Active Storage loaded, and reports
28
+ # itself unavailable rather than failing when it is not.
29
+ #
30
+ # The audit is unaffected by whether quota enforcement is switched on.
31
+ module Audit
32
+ class << self
33
+ # +sample_limit+ bounds how many examples are kept per category. Totals
34
+ # are always exact; only the examples are capped.
35
+ def run(sample_limit: Result::DEFAULT_SAMPLE_LIMIT)
36
+ result = Result.new(sample_limit: sample_limit, consistency: consistency)
37
+
38
+ CounterAudit.new(result: result).run
39
+
40
+ if active_storage_available?
41
+ LedgerAudit.new(result: result, same_connection: same_connection?).run
42
+ else
43
+ %i[
44
+ missing_charge unexpected_charge attachment_count_mismatch
45
+ charge_byte_size_mismatch charge_missing_blob
46
+ ].each { |check| result.unsupported!(check, reason: "Active Storage is not loaded") }
47
+ end
48
+
49
+ result
50
+ end
51
+
52
+ private
53
+ def active_storage_available?
54
+ defined?(ActiveStorage::Attachment) && defined?(ActiveStorage::Blob)
55
+ end
56
+
57
+ # :snapshot when every comparison is same-connection SQL, so each
58
+ # individual check sees one consistent view. :best_effort when blobs
59
+ # live on another connection and have to be compared in Ruby, where
60
+ # concurrent traffic can shift between reads. Cross-database auditing
61
+ # still works; it is simply less exact under load.
62
+ def consistency
63
+ same_connection? ? :snapshot : :best_effort
64
+ end
65
+
66
+ def same_connection?
67
+ return false unless active_storage_available?
68
+
69
+ ActiveStorageQuota::Record.connection_pool.equal?(ActiveStorage::Record.connection_pool)
70
+ rescue StandardError
71
+ false
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module ActiveStorageQuota
6
+ class Backfill
7
+ # An opaque, resumable position in a backfill traversal.
8
+ #
9
+ # Deliberately not a bare blob id. Carrying the record_type scope lets a
10
+ # resume be rejected when it would continue under different traversal
11
+ # semantics, and the version lets the format change later without silently
12
+ # misreading an old token.
13
+ #
14
+ # Encoded as Base64 of JSON through Array#pack, so no gem is needed for it.
15
+ class Cursor
16
+ VERSION = 1
17
+
18
+ attr_reader :record_type, :last_blob_id
19
+
20
+ def initialize(record_type:, last_blob_id:)
21
+ @record_type = record_type
22
+ @last_blob_id = last_blob_id.to_s
23
+ end
24
+
25
+ def self.decode(encoded, record_type:)
26
+ raise ArgumentError, "cursor must be a String, got #{encoded.class}" unless encoded.is_a?(String)
27
+
28
+ payload = parse(encoded)
29
+
30
+ unless payload["v"] == VERSION
31
+ raise ArgumentError,
32
+ "unsupported cursor version #{payload['v'].inspect}; this build reads version #{VERSION}"
33
+ end
34
+
35
+ # Resuming a Contract-scoped traversal under a Document scope would walk
36
+ # different rows while pretending to continue the same run.
37
+ if payload["record_type"] != record_type
38
+ raise ArgumentError,
39
+ "cursor was produced for record_type #{payload['record_type'].inspect} " \
40
+ "but this run is scoped to #{record_type.inspect}"
41
+ end
42
+
43
+ last = payload["last_blob_id"]
44
+ raise ArgumentError, "cursor is missing last_blob_id" unless last.is_a?(String) && !last.empty?
45
+
46
+ new(record_type: record_type, last_blob_id: last)
47
+ end
48
+
49
+ def encode
50
+ [ JSON.generate(v: VERSION, record_type: record_type, last_blob_id: last_blob_id) ].pack("m0")
51
+ end
52
+
53
+ def self.parse(encoded)
54
+ payload = JSON.parse(encoded.unpack1("m0").to_s)
55
+ raise ArgumentError unless payload.is_a?(Hash)
56
+
57
+ payload
58
+ rescue JSON::ParserError, ArgumentError, TypeError
59
+ raise ArgumentError, "cursor is malformed"
60
+ end
61
+ private_class_method :parse
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorageQuota
4
+ class Backfill
5
+ # What a backfill run did, or would have done.
6
+ #
7
+ # Exact totals, bounded samples, scalars only.
8
+ class Result
9
+ DEFAULT_SAMPLE_LIMIT = 100
10
+
11
+ COUNTERS = %i[
12
+ records_scanned attachments_scanned owners_resolved
13
+ accounts_created charges_created charges_already_present bytes_discovered
14
+ batches_completed
15
+ ].freeze
16
+
17
+ attr_reader :sample_limit, :strategy, :counts, :samples, :record_types_processed,
18
+ :charges_created_precision
19
+ attr_accessor :next_cursor
20
+
21
+ # +charges_created_precision+ says how far to trust charges_created:
22
+ #
23
+ # :planned a dry run's projection
24
+ # :exact the adapter returned the rows it actually inserted, so
25
+ # rows skipped by a conflict are excluded
26
+ # :estimated the adapter cannot report which rows it skipped, so this
27
+ # is the number attempted and may overstate what was created
28
+ # if live accounting won a race for the same pair
29
+ def initialize(dry_run:, strategy:, charges_created_precision: :exact,
30
+ sample_limit: DEFAULT_SAMPLE_LIMIT)
31
+ @dry_run = dry_run
32
+ @strategy = strategy
33
+ @charges_created_precision = charges_created_precision
34
+ @sample_limit = sample_limit
35
+ @totals = Hash.new(0)
36
+ @counts = Hash.new(0)
37
+ @samples = Hash.new { |hash, key| hash[key] = [] }
38
+ @record_types_processed = Set.new
39
+ @complete = false
40
+ @next_cursor = nil
41
+ end
42
+
43
+ def dry_run?
44
+ @dry_run
45
+ end
46
+
47
+ # False when charges_created counts rows attempted rather than rows
48
+ # confirmed inserted.
49
+ def charges_created_exact?
50
+ charges_created_precision == :exact
51
+ end
52
+
53
+ def complete?
54
+ @complete
55
+ end
56
+
57
+ def complete!
58
+ @complete = true
59
+ @next_cursor = nil
60
+ self
61
+ end
62
+
63
+ COUNTERS.each { |name| define_method(name) { @totals[name] } }
64
+
65
+ def increment(counter, by: 1)
66
+ @totals[counter] += by
67
+ self
68
+ end
69
+
70
+ def record(category, **details)
71
+ category = category.to_sym
72
+ @counts[category] += 1
73
+ @samples[category] << details if @samples[category].size < sample_limit
74
+
75
+ self
76
+ end
77
+
78
+ def processed_record_type(record_type)
79
+ @record_types_processed << record_type
80
+ self
81
+ end
82
+
83
+ def count(category)
84
+ @counts[category.to_sym]
85
+ end
86
+
87
+ def sample(category)
88
+ @samples[category.to_sym]
89
+ end
90
+
91
+ # Backfill writes charges but never counters, so any charge it created
92
+ # leaves used_bytes understating the ledger until counter reconciliation
93
+ # runs. That is the one sanctioned exception to the runtime invariant, and
94
+ # it is only safe while enforcement is off.
95
+ def counters_require_reconciliation?
96
+ !dry_run? && charges_created.positive?
97
+ end
98
+
99
+ def enforcement_active_owners
100
+ count(:enforcement_active_owner)
101
+ end
102
+
103
+ def errors
104
+ count(:errors)
105
+ end
106
+
107
+ def to_h
108
+ {
109
+ dry_run: dry_run?,
110
+ strategy: strategy,
111
+ complete: complete?,
112
+ next_cursor: next_cursor,
113
+ record_types_processed: record_types_processed.to_a.sort,
114
+ charges_created_precision: charges_created_precision,
115
+ totals: COUNTERS.to_h { |name| [ name, @totals[name] ] },
116
+ categories: @counts.dup,
117
+ counters_require_reconciliation: counters_require_reconciliation?,
118
+ samples: @samples.transform_values(&:dup)
119
+ }
120
+ end
121
+
122
+ def to_s
123
+ headline = dry_run? ? "backfill plan" : "backfill"
124
+ lines = [
125
+ "#{headline} (#{strategy}): #{batches_completed} batch(es), " \
126
+ "#{attachments_scanned} attachment(s), +#{accounts_created} account(s), " \
127
+ "+#{charges_created} charge(s)#{charge_count_note}, #{bytes_discovered} byte(s) discovered"
128
+ ]
129
+ @counts.sort_by { |_, total| -total }.each { |category, total| lines << " #{category}: #{total}" }
130
+ lines << " complete: #{complete?}"
131
+ lines << " next cursor: #{next_cursor}" if next_cursor
132
+ lines << " run reconcile_counters! before enabling enforcement" if counters_require_reconciliation?
133
+ lines.join("\n")
134
+ end
135
+
136
+ private
137
+ def charge_count_note
138
+ case charges_created_precision
139
+ when :estimated then " (attempted; this adapter cannot report skipped rows)"
140
+ when :planned then " (planned)"
141
+ else ""
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,310 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_storage_quota/backfill/cursor"
4
+ require "active_storage_quota/backfill/result"
5
+
6
+ module ActiveStorageQuota
7
+ # Builds the initial charge ledger for an application that installed this gem
8
+ # after it already had Active Storage attachments.
9
+ #
10
+ # ActiveStorageQuota::Backfill.run(dry_run: true)
11
+ # ActiveStorageQuota::Backfill.run(max_batches: 100)
12
+ #
13
+ # Backfill is an operational subsystem, not an application operation, which is
14
+ # why it is not reachable from the top-level module.
15
+ #
16
+ # It is strictly ADDITIVE. It inserts quota accounts and charges and does
17
+ # nothing else: it never updates or deletes a charge, never touches
18
+ # +used_bytes+, never removes an account and never writes an Active Storage
19
+ # row. Contradictions it finds are reported for audit and reconciliation.
20
+ #
21
+ # Because it does not write +used_bytes+, a completed backfill leaves counters
22
+ # understating the ledger until +reconcile_counters!+ runs. That is the single
23
+ # sanctioned exception to the runtime invariant
24
+ #
25
+ # account.used_bytes == account.charges.sum(:byte_size)
26
+ #
27
+ # and it is only safe while quota limits resolve to nil. See the README
28
+ # section "Adopting active_storage_quota in an existing application".
29
+ class Backfill
30
+ DEFAULT_BATCH_SIZE = 500
31
+
32
+ class << self
33
+ def run(record_type: nil, cursor: nil, batch_size: DEFAULT_BATCH_SIZE,
34
+ max_batches: nil, dry_run: false, allow_active_enforcement: false,
35
+ sample_limit: Result::DEFAULT_SAMPLE_LIMIT)
36
+ new(
37
+ record_type: validate_record_type(record_type),
38
+ batch_size: validate_batch_size(batch_size),
39
+ max_batches: validate_max_batches(max_batches),
40
+ cursor: cursor,
41
+ dry_run: dry_run,
42
+ allow_active_enforcement: allow_active_enforcement,
43
+ sample_limit: sample_limit
44
+ ).run
45
+ end
46
+
47
+ private
48
+ def validate_batch_size(value)
49
+ return value if value.is_a?(Integer) && value.positive?
50
+
51
+ raise ArgumentError, "batch_size must be a positive Integer, got #{value.inspect}"
52
+ end
53
+
54
+ def validate_max_batches(value)
55
+ return value if value.nil? || (value.is_a?(Integer) && value.positive?)
56
+
57
+ raise ArgumentError, "max_batches must be nil or a positive Integer, got #{value.inspect}"
58
+ end
59
+
60
+ def validate_record_type(value)
61
+ return value if value.nil? || (value.is_a?(String) && !value.strip.empty?)
62
+
63
+ raise ArgumentError, "record_type must be nil or a non-empty String, got #{value.inspect}"
64
+ end
65
+ end
66
+
67
+ def initialize(record_type:, batch_size:, max_batches:, cursor:, dry_run:,
68
+ allow_active_enforcement:, sample_limit:)
69
+ @record_type = record_type
70
+ @batch_size = batch_size
71
+ @max_batches = max_batches
72
+ @dry_run = dry_run
73
+ @allow_active_enforcement = allow_active_enforcement
74
+ @result = Result.new(dry_run: dry_run, strategy: strategy, sample_limit: sample_limit,
75
+ charges_created_precision: charges_created_precision(dry_run))
76
+ @resolver = OwnerResolver.new(result: @result)
77
+ @cursor = cursor && Cursor.decode(cursor, record_type: record_type)
78
+ @limits_seen = {}
79
+ end
80
+
81
+ def run
82
+ last_blob_id = @cursor&.last_blob_id
83
+ batches = 0
84
+
85
+ loop do
86
+ blob_ids = next_blob_ids(last_blob_id)
87
+ break result.complete! if blob_ids.empty?
88
+
89
+ process(blob_ids)
90
+
91
+ last_blob_id = blob_ids.last.to_s
92
+ batches += 1
93
+ result.increment(:batches_completed)
94
+
95
+ if @max_batches && batches >= @max_batches
96
+ result.next_cursor = Cursor.new(record_type: @record_type, last_blob_id: last_blob_id).encode
97
+ break
98
+ end
99
+ end
100
+
101
+ result
102
+ end
103
+
104
+ private
105
+ attr_reader :result
106
+
107
+ # Keyset over distinct blob ids. Ordering only has to be stable and total,
108
+ # which holds for bigint, uuid and text keys alike; nothing here assumes
109
+ # ids are numeric or chronological, and MAX(id) is never consulted.
110
+ #
111
+ # Batching by blob keeps every (account, blob) group inside one batch, so
112
+ # counts are exact.
113
+ def next_blob_ids(last_blob_id)
114
+ scope = ActiveStorage::Attachment.order(:blob_id).limit(@batch_size)
115
+ scope = scope.where(record_type: @record_type) if @record_type
116
+ if last_blob_id
117
+ scope = scope.where(ActiveStorage::Attachment.arel_table[:blob_id].gt(last_blob_id))
118
+ end
119
+
120
+ scope.distinct.pluck(:blob_id)
121
+ end
122
+
123
+ def process(blob_ids)
124
+ owner_pairs = @resolver.owner_pairs_for_blobs(blob_ids)
125
+ result.increment(:attachments_scanned, by: owner_pairs.values.sum)
126
+ return if owner_pairs.empty?
127
+
128
+ owner_keys = owner_pairs.keys.map { |type, id, _blob| [ type, id ] }.uniq
129
+ result.increment(:owners_resolved, by: owner_keys.size)
130
+ record_types_from(owner_pairs)
131
+
132
+ # Checked before anything in this batch is written, so a run that would
133
+ # enforce against incomplete counters stops before it starts.
134
+ guard_enforcement!(owner_keys)
135
+
136
+ accounts = ensure_accounts(owner_keys)
137
+ sizes = blob_sizes(blob_ids)
138
+ existing = existing_charges(accounts.values.compact, blob_ids)
139
+
140
+ insert_charges(build_rows(owner_pairs, accounts, sizes, existing))
141
+ end
142
+
143
+ def record_types_from(owner_pairs)
144
+ result.processed_record_type(@record_type) if @record_type
145
+ result.increment(:records_scanned, by: owner_pairs.size)
146
+ end
147
+
148
+ def build_rows(owner_pairs, accounts, sizes, existing)
149
+ owner_pairs.filter_map do |(owner_type, owner_id, blob_id), count|
150
+ account_id = accounts[[ owner_type, owner_id ]]
151
+ byte_size = sizes[blob_id]
152
+
153
+ # Never invent a size. An attachment pointing at a missing blob is
154
+ # malformed data to report, not to guess at.
155
+ if byte_size.nil?
156
+ result.record(:missing_blob, owner_type: owner_type, owner_id: owner_id, blob_id: blob_id)
157
+ next
158
+ end
159
+
160
+ if account_id && (charge = existing[[ account_id, blob_id ]])
161
+ note_existing(charge, account_id, blob_id, byte_size)
162
+ next
163
+ end
164
+
165
+ next if account_id.nil? && !@dry_run
166
+
167
+ { quota_account_id: account_id, blob_id: blob_id, byte_size: byte_size,
168
+ attachment_count: count }
169
+ end
170
+ end
171
+
172
+ # An existing charge is left completely alone -- count, byte size, owner.
173
+ # Repairing those is reconciliation's job, and doing it here would mean a
174
+ # second concurrency model for the same problem.
175
+ def note_existing(charge, account_id, blob_id, byte_size)
176
+ result.increment(:charges_already_present)
177
+ return if charge == byte_size
178
+
179
+ result.record(:report_only_mismatch, quota_account_id: account_id, blob_id: blob_id,
180
+ charge_byte_size: charge, blob_byte_size: byte_size)
181
+ end
182
+
183
+ def insert_charges(rows)
184
+ return if rows.empty?
185
+
186
+ result.increment(:bytes_discovered, by: rows.sum { |row| row[:byte_size] })
187
+
188
+ if @dry_run
189
+ result.increment(:charges_created, by: rows.size)
190
+ return
191
+ end
192
+
193
+ # Conflict means skip, never upsert: a live callback's delta must never
194
+ # be overwritten with a historical absolute count.
195
+ inserted = Charge.insert_all(rows, unique_by: %i[quota_account_id blob_id],
196
+ record_timestamps: true)
197
+ result.increment(:charges_created, by: inserted_count(inserted, rows))
198
+ end
199
+
200
+ # insert_all only asks for RETURNING when the adapter supports it
201
+ # (ActiveRecord::InsertAll sets @returning from supports_insert_returning?).
202
+ # Where it does, the returned rows are exactly the ones inserted, so a row
203
+ # skipped by a conflict is correctly excluded. Where it does not, the
204
+ # result carries no rows at all -- reading its length would report zero --
205
+ # so the count falls back to rows attempted and the result says so rather
206
+ # than presenting an estimate as a fact.
207
+ def inserted_count(inserted, rows)
208
+ return rows.size unless result.charges_created_exact?
209
+
210
+ inserted.length
211
+ end
212
+
213
+ def charges_created_precision(dry_run)
214
+ return :planned if dry_run
215
+
216
+ insert_returning_supported? ? :exact : :estimated
217
+ end
218
+
219
+ def insert_returning_supported?
220
+ Charge.connection_pool.with_connection(&:supports_insert_returning?)
221
+ rescue StandardError
222
+ false
223
+ end
224
+
225
+ def ensure_accounts(owner_keys)
226
+ existing = @resolver.send(:accounts_by_owner, owner_keys)
227
+ missing = owner_keys - existing.keys
228
+ return existing if missing.empty?
229
+
230
+ if @dry_run
231
+ result.increment(:accounts_created, by: missing.size)
232
+ return existing
233
+ end
234
+
235
+ Account.insert_all(
236
+ missing.map do |owner_type, owner_id|
237
+ { owner_type: owner_type, owner_id: owner_id,
238
+ scope_name: ActiveStorageQuota::DEFAULT_SCOPE, used_bytes: 0, reserved_bytes: 0 }
239
+ end,
240
+ unique_by: %i[owner_type owner_id scope_name],
241
+ record_timestamps: true
242
+ )
243
+
244
+ refreshed = @resolver.send(:accounts_by_owner, owner_keys)
245
+ result.increment(:accounts_created, by: (refreshed.keys - existing.keys).size)
246
+ refreshed
247
+ end
248
+
249
+ # Backfill leaves used_bytes understating the ledger until counter
250
+ # reconciliation runs, so enforcing against it would admit uploads that
251
+ # should have been refused.
252
+ def guard_enforcement!(owner_keys)
253
+ active = owner_keys.select { |key| enforcement_active?(key) }
254
+ return if active.empty?
255
+
256
+ active.each do |owner_type, owner_id|
257
+ result.record(:enforcement_active_owner, owner_type: owner_type, owner_id: owner_id)
258
+ end
259
+
260
+ return if @dry_run || @allow_active_enforcement
261
+
262
+ raise EnforcementActive.new(owners: active)
263
+ end
264
+
265
+ def enforcement_active?(owner_key)
266
+ @limits_seen.fetch(owner_key) { @limits_seen[owner_key] = resolve_limit(owner_key) }
267
+ end
268
+
269
+ def resolve_limit(owner_key)
270
+ owner_type, owner_id = owner_key
271
+ klass = owner_type.safe_constantize
272
+ return false if klass.nil?
273
+
274
+ owner = klass.find_by(klass.primary_key => owner_id)
275
+ return false if owner.nil? || !owner.respond_to?(:storage_limit)
276
+
277
+ !owner.storage_limit.nil?
278
+ rescue StandardError
279
+ false
280
+ end
281
+
282
+ def blob_sizes(blob_ids)
283
+ ActiveStorage::Blob.where(id: blob_ids).pluck(:id, :byte_size)
284
+ .each_with_object({}) { |(id, size), map| map[id.to_s] = size }
285
+ end
286
+
287
+ def existing_charges(account_ids, blob_ids)
288
+ return {} if account_ids.empty?
289
+
290
+ Charge.where(quota_account_id: account_ids, blob_id: blob_ids)
291
+ .pluck(:quota_account_id, :blob_id, :byte_size)
292
+ .each_with_object({}) { |(account_id, blob_id, size), map| map[[ account_id, blob_id.to_s ]] = size }
293
+ end
294
+
295
+ # Reads come from Active Storage, writes go to the quota tables. Neither
296
+ # depends on a cross-database join, so a separate Active Storage database
297
+ # costs the strategy label and nothing else.
298
+ def strategy
299
+ self.class.send(:same_connection?) ? :same_connection : :cross_connection
300
+ end
301
+
302
+ class << self
303
+ def same_connection?
304
+ ActiveStorageQuota::Record.connection_pool.equal?(ActiveStorage::Record.connection_pool)
305
+ rescue StandardError
306
+ false
307
+ end
308
+ end
309
+ end
310
+ end