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