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,297 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveStorageQuota
|
|
4
|
+
# A claim on an owner's storage, held while an upload is in flight.
|
|
5
|
+
#
|
|
6
|
+
# A reservation is created +active+ and reaches exactly one terminal state:
|
|
7
|
+
#
|
|
8
|
+
# active --> committed the bytes became stored data
|
|
9
|
+
# active --> released the upload failed, expired, or was abandoned
|
|
10
|
+
#
|
|
11
|
+
# Terminal rows are kept. That makes +commit!+ and +release!+ idempotent
|
|
12
|
+
# under job retries, and leaves an account's whole history readable in one
|
|
13
|
+
# query when its counters need explaining.
|
|
14
|
+
#
|
|
15
|
+
# Every transition is a conditional UPDATE guarded on the current status.
|
|
16
|
+
# Winning that UPDATE -- getting one affected row -- is what grants the right
|
|
17
|
+
# to move the account's counters, and both happen in one transaction. Two
|
|
18
|
+
# callers racing on the same reservation therefore cannot both move its
|
|
19
|
+
# bytes: the loser's UPDATE matches nothing.
|
|
20
|
+
class Reservation < Record
|
|
21
|
+
self.table_name = "active_storage_quota_reservations"
|
|
22
|
+
|
|
23
|
+
STATUS_ACTIVE = "active"
|
|
24
|
+
STATUS_COMMITTED = "committed"
|
|
25
|
+
STATUS_RELEASED = "released"
|
|
26
|
+
STATUSES = [ STATUS_ACTIVE, STATUS_COMMITTED, STATUS_RELEASED ].freeze
|
|
27
|
+
|
|
28
|
+
belongs_to :quota_account,
|
|
29
|
+
class_name: "ActiveStorageQuota::Account",
|
|
30
|
+
inverse_of: :reservations
|
|
31
|
+
|
|
32
|
+
validates :byte_size, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
|
|
33
|
+
validates :status, inclusion: { in: STATUSES }
|
|
34
|
+
validates :expires_at, presence: true
|
|
35
|
+
|
|
36
|
+
class << self
|
|
37
|
+
# Releases reservations whose expiry has passed, returning how many this
|
|
38
|
+
# invocation actually transitioned.
|
|
39
|
+
#
|
|
40
|
+
# Safe to run from several processes at once with no coordination. Two
|
|
41
|
+
# sweepers may select the same rows; only one can win each guarded
|
|
42
|
+
# transition, and only won transitions are counted.
|
|
43
|
+
#
|
|
44
|
+
# ActiveStorageQuota.release_expired_reservations!(limit: 1_000)
|
|
45
|
+
def release_expired!(limit: 1_000)
|
|
46
|
+
unless limit.is_a?(Integer) && limit.positive?
|
|
47
|
+
raise ArgumentError, "limit must be a positive Integer, got #{limit.inspect}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
now = Time.current
|
|
51
|
+
|
|
52
|
+
expired(now).limit(limit).order(:id).to_a.count do |reservation|
|
|
53
|
+
reservation.release_if_expired!(now)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Consumes the hold covering +charge+, if this account holds one.
|
|
58
|
+
#
|
|
59
|
+
# Matching is account-scoped on purpose. A reservation taken by one owner
|
|
60
|
+
# for a blob must never be consumed or released by another owner attaching
|
|
61
|
+
# that same blob: it stays active and is reclaimed by its own TTL.
|
|
62
|
+
def consume_matching!(charge:, now: Time.current)
|
|
63
|
+
candidate = where(
|
|
64
|
+
quota_account_id: charge.quota_account_id,
|
|
65
|
+
blob_id: charge.blob_id,
|
|
66
|
+
byte_size: charge.byte_size,
|
|
67
|
+
status: STATUS_ACTIVE
|
|
68
|
+
).where("expires_at > ?", now).order(:id).first
|
|
69
|
+
|
|
70
|
+
return false if candidate.nil?
|
|
71
|
+
|
|
72
|
+
candidate.consume!(charge, now)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Hands back a hold that turned out to be unnecessary, because the blob
|
|
76
|
+
# was already charged to this account. Tolerates losing the race.
|
|
77
|
+
def release_matching!(quota_account_id:, blob_id:, now: Time.current)
|
|
78
|
+
candidate = where(
|
|
79
|
+
quota_account_id: quota_account_id,
|
|
80
|
+
blob_id: blob_id.to_s,
|
|
81
|
+
status: STATUS_ACTIVE
|
|
82
|
+
).where("expires_at > ?", now).order(:id).first
|
|
83
|
+
|
|
84
|
+
return false if candidate.nil?
|
|
85
|
+
|
|
86
|
+
candidate.release!
|
|
87
|
+
rescue InvalidReservation
|
|
88
|
+
false
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Releases every active hold for a blob, returning how many this call
|
|
92
|
+
# actually released.
|
|
93
|
+
#
|
|
94
|
+
# A destroyed blob can never be attached, so no hold naming it can ever be
|
|
95
|
+
# consumed; keeping the bytes reserved until the TTL expires would be pure
|
|
96
|
+
# waste. Direct uploads make this common, since an abandoned upload is
|
|
97
|
+
# usually purged as an unattached blob long before its hold lapses.
|
|
98
|
+
#
|
|
99
|
+
# Every hold for the blob is released regardless of account: they are all
|
|
100
|
+
# equally unusable. Each goes through the ordinary guarded release, so this
|
|
101
|
+
# is idempotent and races with the expiry sweeper the same way every other
|
|
102
|
+
# transition does -- whichever wins, the bytes come back exactly once.
|
|
103
|
+
def release_active_for_blob!(blob_id:, now: Time.current)
|
|
104
|
+
where(blob_id: blob_id.to_s, status: STATUS_ACTIVE).order(:id).to_a.count do |reservation|
|
|
105
|
+
reservation.release!
|
|
106
|
+
rescue InvalidReservation
|
|
107
|
+
# Committed or released between the read and the write. Not our bytes.
|
|
108
|
+
false
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Active reservations whose expiry has passed. The exact complement of
|
|
113
|
+
# the guard commit! uses, so at any instant an active reservation is
|
|
114
|
+
# claimable by exactly one of the two.
|
|
115
|
+
def expired(now = Time.current)
|
|
116
|
+
where(status: STATUS_ACTIVE).where("expires_at <= ?", now)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def active?
|
|
121
|
+
status == STATUS_ACTIVE
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def committed?
|
|
125
|
+
status == STATUS_COMMITTED
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def released?
|
|
129
|
+
status == STATUS_RELEASED
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Whether this reservation's hard expiry has passed. Compared against the
|
|
133
|
+
# application clock rather than the database's, so expiry can be tested
|
|
134
|
+
# with travel_to and behaves identically on every adapter.
|
|
135
|
+
def expired?(now = Time.current)
|
|
136
|
+
expires_at <= now
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Consumes this hold on behalf of +charge+, in the charge's transaction.
|
|
140
|
+
#
|
|
141
|
+
# Returns false when the guarded transition matched nothing -- the sweeper
|
|
142
|
+
# released it, it expired, or another charge consumed it first. That is a
|
|
143
|
+
# benign lost race, not an accounting failure: the caller simply claims
|
|
144
|
+
# fresh capacity instead. An exception here means something worse, that the
|
|
145
|
+
# transition WAS won but the counters could not be moved.
|
|
146
|
+
#
|
|
147
|
+
# Requires a persisted, matching charge, so a hold can never become usage
|
|
148
|
+
# without a ledger row to account for it. That is what makes
|
|
149
|
+
# used_bytes == SUM(charges.byte_size) structural.
|
|
150
|
+
def consume!(charge, now = Time.current)
|
|
151
|
+
validate_charge!(charge)
|
|
152
|
+
|
|
153
|
+
# Its own savepoint, so the status transition and the counter move are
|
|
154
|
+
# atomic even though this normally runs inside Charge's transaction. If
|
|
155
|
+
# the counters cannot be moved, the transition is undone rather than
|
|
156
|
+
# leaving a committed reservation whose bytes were never transferred.
|
|
157
|
+
claimed = self.class.transaction(requires_new: true) do
|
|
158
|
+
won = self.class
|
|
159
|
+
.where(id: id, status: STATUS_ACTIVE, quota_account_id: charge.quota_account_id,
|
|
160
|
+
blob_id: charge.blob_id, byte_size: charge.byte_size)
|
|
161
|
+
.where("expires_at > ?", now)
|
|
162
|
+
.update_all(status: STATUS_COMMITTED, updated_at: now) == 1
|
|
163
|
+
|
|
164
|
+
move_reserved_to_used!(now) if won
|
|
165
|
+
won
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
return false unless claimed
|
|
169
|
+
|
|
170
|
+
reload
|
|
171
|
+
true
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Hands this reservation's bytes back to the account.
|
|
175
|
+
#
|
|
176
|
+
# Returns true when the reservation is released, including when it was
|
|
177
|
+
# already released. Raises InvalidReservation if it was committed, since
|
|
178
|
+
# those bytes are stored data now.
|
|
179
|
+
#
|
|
180
|
+
# Deliberately has no expiry guard: freeing capacity is always safe, so an
|
|
181
|
+
# expired reservation can still be released explicitly.
|
|
182
|
+
def release!
|
|
183
|
+
now = Time.current
|
|
184
|
+
|
|
185
|
+
claimed = self.class.transaction(requires_new: true) do
|
|
186
|
+
if claim_transition(to: STATUS_RELEASED, now: now)
|
|
187
|
+
return_reserved!(now)
|
|
188
|
+
true
|
|
189
|
+
else
|
|
190
|
+
false
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
return explain_failed_release! unless claimed
|
|
195
|
+
|
|
196
|
+
reload
|
|
197
|
+
true
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# The sweeper's transition. Same as release!, but guarded on the expiry
|
|
201
|
+
# having passed so a reservation that is still valid is never swept.
|
|
202
|
+
#
|
|
203
|
+
# Returns true only if this call is the one that released it.
|
|
204
|
+
def release_if_expired!(now = Time.current)
|
|
205
|
+
claimed = self.class.transaction(requires_new: true) do
|
|
206
|
+
if claim_transition(to: STATUS_RELEASED, now: now) { |rows| rows.where("expires_at <= ?", now) }
|
|
207
|
+
return_reserved!(now)
|
|
208
|
+
true
|
|
209
|
+
else
|
|
210
|
+
false
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
reload if claimed
|
|
215
|
+
claimed
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
private
|
|
219
|
+
def validate_charge!(charge)
|
|
220
|
+
return if charge.is_a?(Charge) && charge.persisted? &&
|
|
221
|
+
charge.quota_account_id == quota_account_id &&
|
|
222
|
+
charge.byte_size == byte_size &&
|
|
223
|
+
blob_id.present? && charge.blob_id == blob_id
|
|
224
|
+
|
|
225
|
+
raise ArgumentError,
|
|
226
|
+
"reservation #{id} can only be consumed by a persisted charge on " \
|
|
227
|
+
"the same account, for the same blob, for the same number of " \
|
|
228
|
+
"bytes. A reservation taken without a blob can only be released."
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# The guarded state transition. One UPDATE, matching only while this row
|
|
232
|
+
# is still active, so exactly one concurrent caller can win it.
|
|
233
|
+
def claim_transition(to:, now:)
|
|
234
|
+
rows = self.class.where(id: id, status: STATUS_ACTIVE)
|
|
235
|
+
rows = yield(rows) if block_given?
|
|
236
|
+
|
|
237
|
+
rows.update_all(status: to, updated_at: now) == 1
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def move_reserved_to_used!(now)
|
|
241
|
+
apply_counters!(
|
|
242
|
+
now,
|
|
243
|
+
[ "reserved_bytes = reserved_bytes - ?, used_bytes = used_bytes + ?, updated_at = ?",
|
|
244
|
+
byte_size, byte_size, now ]
|
|
245
|
+
)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def return_reserved!(now)
|
|
249
|
+
apply_counters!(
|
|
250
|
+
now,
|
|
251
|
+
[ "reserved_bytes = reserved_bytes - ?, updated_at = ?", byte_size, now ]
|
|
252
|
+
)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Moves the account's counters, refusing to act unless the account still
|
|
256
|
+
# holds the bytes this reservation claims.
|
|
257
|
+
#
|
|
258
|
+
# The reserved_bytes >= byte_size guard is what keeps a drifted counter
|
|
259
|
+
# from becoming a negative one. Failing here rolls back the status
|
|
260
|
+
# transition made moments ago in this same transaction, so the
|
|
261
|
+
# reservation stays active and the account is untouched.
|
|
262
|
+
def apply_counters!(_now, updates)
|
|
263
|
+
affected = Account
|
|
264
|
+
.where(id: quota_account_id)
|
|
265
|
+
.where("reserved_bytes >= ?", byte_size)
|
|
266
|
+
.update_all(updates)
|
|
267
|
+
|
|
268
|
+
return if affected == 1
|
|
269
|
+
|
|
270
|
+
raise CounterInvariantViolation,
|
|
271
|
+
"reservation #{id} claims #{byte_size} reserved bytes, but account " \
|
|
272
|
+
"#{quota_account_id} does not hold that many; refusing to move " \
|
|
273
|
+
"counters. The account's counters have drifted from its reservations."
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Only reached when the guarded UPDATE matched nothing, so it costs one
|
|
277
|
+
# SELECT on the failure path and none on the success path.
|
|
278
|
+
def explain_failed_release!
|
|
279
|
+
current = self.class.find_by(id: id)
|
|
280
|
+
|
|
281
|
+
raise InvalidReservation, "reservation #{id} no longer exists" if current.nil?
|
|
282
|
+
|
|
283
|
+
case current.status
|
|
284
|
+
when STATUS_RELEASED
|
|
285
|
+
reload
|
|
286
|
+
true
|
|
287
|
+
when STATUS_COMMITTED
|
|
288
|
+
raise InvalidReservation,
|
|
289
|
+
"reservation #{id} was committed and cannot be released; " \
|
|
290
|
+
"those bytes are stored data"
|
|
291
|
+
else
|
|
292
|
+
raise InvalidReservation,
|
|
293
|
+
"reservation #{id} changed state concurrently and could not be released"
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
end
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support"
|
|
4
|
+
|
|
5
|
+
require "active_storage_quota/version"
|
|
6
|
+
require "active_storage_quota/errors"
|
|
7
|
+
require "active_storage_quota/configuration"
|
|
8
|
+
require "active_storage_quota/definition"
|
|
9
|
+
require "active_storage_quota/owner_source"
|
|
10
|
+
require "active_storage_quota/owner_resolver"
|
|
11
|
+
|
|
12
|
+
# Storage quota management for Rails Active Storage.
|
|
13
|
+
#
|
|
14
|
+
# See README.md for usage. The short version:
|
|
15
|
+
#
|
|
16
|
+
# class Company < ApplicationRecord
|
|
17
|
+
# has_storage_quota limit: 5.gigabytes
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# company.storage_remaining # => 5368709120
|
|
21
|
+
module ActiveStorageQuota
|
|
22
|
+
# Quota accounts are keyed by owner *and* scope. Owners declared with
|
|
23
|
+
# +has_storage_quota+ use this scope; named scopes arrive in a later version.
|
|
24
|
+
DEFAULT_SCOPE = "default"
|
|
25
|
+
|
|
26
|
+
# The Active-Record-dependent public API, declared with autoload rather than
|
|
27
|
+
# required from the :active_record load hook.
|
|
28
|
+
#
|
|
29
|
+
# That hook only fires when ActiveRecord::Base is first referenced. A rake
|
|
30
|
+
# task has not touched a model by the time it runs, so requiring these there
|
|
31
|
+
# left every one of them undefined -- which broke every task this gem ships.
|
|
32
|
+
# Autoload resolves them on first reference instead, whenever that happens.
|
|
33
|
+
autoload :Record, "active_storage_quota/record"
|
|
34
|
+
autoload :Account, "active_storage_quota/account"
|
|
35
|
+
autoload :Reservation, "active_storage_quota/reservation"
|
|
36
|
+
autoload :Charge, "active_storage_quota/charge"
|
|
37
|
+
autoload :Audit, "active_storage_quota/audit"
|
|
38
|
+
autoload :Reconciliation, "active_storage_quota/reconciliation"
|
|
39
|
+
autoload :Backfill, "active_storage_quota/backfill"
|
|
40
|
+
|
|
41
|
+
class << self
|
|
42
|
+
# The current Configuration.
|
|
43
|
+
def config
|
|
44
|
+
@config ||= Configuration.new
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Yields the Configuration for an initializer block.
|
|
48
|
+
def configure
|
|
49
|
+
yield config
|
|
50
|
+
config
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Restores every setting to its default. Intended for test suites.
|
|
54
|
+
def reset_configuration!
|
|
55
|
+
@config = Configuration.new
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Where accounting failures are reported. Defaults to Rails' logger when
|
|
59
|
+
# Rails is present, otherwise Active Record's.
|
|
60
|
+
attr_writer :logger
|
|
61
|
+
|
|
62
|
+
def logger
|
|
63
|
+
return @logger if defined?(@logger) && @logger
|
|
64
|
+
|
|
65
|
+
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
66
|
+
Rails.logger
|
|
67
|
+
elsif defined?(ActiveRecord::Base)
|
|
68
|
+
ActiveRecord::Base.logger
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Reports an accounting failure that was deliberately not raised.
|
|
73
|
+
#
|
|
74
|
+
# Deletion accounting fails open: it can never undo a deletion, because
|
|
75
|
+
# blocking a delete on drifted counters would trap an over-quota account
|
|
76
|
+
# with no way back under its limit. Failing open without saying so would be
|
|
77
|
+
# worse, so every swallowed failure is logged and instrumented.
|
|
78
|
+
#
|
|
79
|
+
# Subscribe with:
|
|
80
|
+
#
|
|
81
|
+
# ActiveSupport::Notifications.subscribe("active_storage_quota.accounting_failed") do |*, payload|
|
|
82
|
+
# Sentry.capture_message(payload[:exception_message])
|
|
83
|
+
# end
|
|
84
|
+
#
|
|
85
|
+
# The payload carries scalars only, never Active Record objects.
|
|
86
|
+
def accounting_failed(operation:, exception:, quota_account_id: nil, blob_id: nil,
|
|
87
|
+
record_type: nil, record_id: nil)
|
|
88
|
+
payload = {
|
|
89
|
+
operation: operation,
|
|
90
|
+
quota_account_id: quota_account_id,
|
|
91
|
+
blob_id: blob_id,
|
|
92
|
+
record_type: record_type,
|
|
93
|
+
record_id: record_id&.to_s,
|
|
94
|
+
exception_class: exception.class.name,
|
|
95
|
+
exception_message: exception.message
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
logger&.error do
|
|
99
|
+
"[active_storage_quota] #{operation} accounting failed for account " \
|
|
100
|
+
"#{quota_account_id.inspect} blob #{blob_id.inspect}: " \
|
|
101
|
+
"#{exception.class}: #{exception.message}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
ActiveSupport::Notifications.instrument("active_storage_quota.accounting_failed", payload)
|
|
105
|
+
|
|
106
|
+
nil
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Inspects quota accounting and reports what it finds, writing nothing.
|
|
110
|
+
#
|
|
111
|
+
# result = ActiveStorageQuota.audit
|
|
112
|
+
# puts result unless result.clean?
|
|
113
|
+
#
|
|
114
|
+
# See ActiveStorageQuota::Audit. Safe to run against production: no repairs,
|
|
115
|
+
# no deletions, no account creation, and no dependence on whether quota
|
|
116
|
+
# enforcement is switched on.
|
|
117
|
+
def audit(sample_limit: Audit::Result::DEFAULT_SAMPLE_LIMIT)
|
|
118
|
+
Audit.run(sample_limit: sample_limit)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Repairs Account#used_bytes from the charges it aggregates.
|
|
122
|
+
#
|
|
123
|
+
# ActiveStorageQuota.reconcile_counters!(owner: company)
|
|
124
|
+
#
|
|
125
|
+
# Cheap and safe to run often. Locks each account row briefly so concurrent
|
|
126
|
+
# uploads cannot lose bytes to it.
|
|
127
|
+
def reconcile_counters!(owner:, dry_run: false, **options)
|
|
128
|
+
Reconciliation.counters!(owner: owner, dry_run: dry_run, **options)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Repairs charges from live Active Storage ownership.
|
|
132
|
+
#
|
|
133
|
+
# ActiveStorageQuota.reconcile_ledger!(owner: company, dry_run: true)
|
|
134
|
+
#
|
|
135
|
+
# Destructive repair -- removing charges nothing backs any more -- happens
|
|
136
|
+
# only when a budget is supplied; see Reconciliation::Budget.
|
|
137
|
+
def reconcile_ledger!(owner:, dry_run: false, **options)
|
|
138
|
+
Reconciliation.ledger!(owner: owner, dry_run: dry_run, **options)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Ledger repair followed by counter repair.
|
|
142
|
+
#
|
|
143
|
+
# ActiveStorageQuota.reconcile!(owner: company, dry_run: true)
|
|
144
|
+
# ActiveStorageQuota.reconcile!(owner: company,
|
|
145
|
+
# max_charge_deletions: 10,
|
|
146
|
+
# max_removed_fraction: 0.25)
|
|
147
|
+
#
|
|
148
|
+
# +owner+ is required. A reconciliation across every tenant is reachable
|
|
149
|
+
# only through the rake task, which makes you ask for it explicitly -- it is
|
|
150
|
+
# the operation most worth thinking about before running.
|
|
151
|
+
def reconcile!(owner:, dry_run: false, **options)
|
|
152
|
+
Reconciliation.all!(owner: owner, dry_run: dry_run, **options)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Releases reservations whose expiry has passed, returning how many this
|
|
156
|
+
# call actually released.
|
|
157
|
+
#
|
|
158
|
+
# ActiveStorageQuota.release_expired_reservations!
|
|
159
|
+
# ActiveStorageQuota.release_expired_reservations!(limit: 5_000)
|
|
160
|
+
#
|
|
161
|
+
# A reservation is abandoned whenever the process holding it dies before
|
|
162
|
+
# committing or releasing. Without this, those bytes stay reserved forever.
|
|
163
|
+
#
|
|
164
|
+
# There is no scheduler here on purpose: run it from cron, a recurring job,
|
|
165
|
+
# or the bundled rake task, as often as your reservation_ttl warrants. It is
|
|
166
|
+
# safe to run several at once -- no locks, no leader election -- because
|
|
167
|
+
# each reservation can only be released by whichever call wins its guarded
|
|
168
|
+
# transition.
|
|
169
|
+
#
|
|
170
|
+
# +limit+ bounds one invocation so a backlog cannot turn into an unbounded
|
|
171
|
+
# run. It must be a positive Integer.
|
|
172
|
+
def release_expired_reservations!(limit: 1_000)
|
|
173
|
+
Reservation.release_expired!(limit: limit)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Active Record may be loaded before or after this gem. Deferring to the load
|
|
179
|
+
# hook means the models are defined once Active Record is ready either way, and
|
|
180
|
+
# the gem works with or without Rails booted.
|
|
181
|
+
# The macros have to be installed on ActiveRecord::Base the moment it loads;
|
|
182
|
+
# everything else resolves through the autoloads above, on first reference.
|
|
183
|
+
ActiveSupport.on_load(:active_record) do
|
|
184
|
+
require "active_storage_quota/owner"
|
|
185
|
+
require "active_storage_quota/attachable"
|
|
186
|
+
|
|
187
|
+
extend ActiveStorageQuota::Owner::Macro
|
|
188
|
+
extend ActiveStorageQuota::Attachable::Macro
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Active Storage integration. These hooks are the documented extension point for
|
|
192
|
+
# both models and exist in every supported Rails version, so nothing here
|
|
193
|
+
# patches Active Storage. When Active Storage is never loaded, they simply never
|
|
194
|
+
# run -- which is why it is a development dependency and not a runtime one.
|
|
195
|
+
ActiveSupport.on_load(:active_storage_attachment) do
|
|
196
|
+
require "active_storage_quota/attachment_accounting"
|
|
197
|
+
|
|
198
|
+
include ActiveStorageQuota::AttachmentAccounting
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
ActiveSupport.on_load(:active_storage_blob) do
|
|
202
|
+
require "active_storage_quota/blob_accounting"
|
|
203
|
+
|
|
204
|
+
include ActiveStorageQuota::BlobAccounting
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
require "active_storage_quota/engine" if defined?(Rails::Engine)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
namespace :active_storage_quota do
|
|
4
|
+
desc "Release quota reservations whose expiry has passed"
|
|
5
|
+
task release_expired_reservations: :environment do
|
|
6
|
+
limit = Integer(ENV.fetch("LIMIT", 1_000))
|
|
7
|
+
released = ActiveStorageQuota.release_expired_reservations!(limit: limit)
|
|
8
|
+
|
|
9
|
+
puts "Released #{released} expired reservation#{'s' unless released == 1}."
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
namespace :active_storage_quota do
|
|
4
|
+
desc "Report quota accounting inconsistencies (read only, changes nothing)"
|
|
5
|
+
task audit: :environment do
|
|
6
|
+
result = ActiveStorageQuota.audit(
|
|
7
|
+
sample_limit: Integer(ENV.fetch("SAMPLE_LIMIT", 100))
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
puts result
|
|
11
|
+
result.findings.each { |finding| puts " - #{finding}" } if ENV["VERBOSE"]
|
|
12
|
+
|
|
13
|
+
exit(1) unless result.clean?
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
|
|
5
|
+
namespace :active_storage_quota do
|
|
6
|
+
desc "Show what historical backfill would create (dry run, never writes)"
|
|
7
|
+
task backfill: :environment do
|
|
8
|
+
ActiveStorageQuota::BackfillRakeSupport.run(dry_run: true)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
namespace :backfill do
|
|
12
|
+
desc "Create historical quota charges (writes)"
|
|
13
|
+
task apply: :environment do
|
|
14
|
+
ActiveStorageQuota::BackfillRakeSupport.run(dry_run: false)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
module ActiveStorageQuota
|
|
20
|
+
module BackfillRakeSupport # :nodoc:
|
|
21
|
+
class << self
|
|
22
|
+
def run(dry_run:)
|
|
23
|
+
record_type = ENV["RECORD_TYPE"].presence
|
|
24
|
+
require_explicit_scope!(record_type) unless dry_run
|
|
25
|
+
|
|
26
|
+
result = ActiveStorageQuota::Backfill.run(
|
|
27
|
+
record_type: record_type,
|
|
28
|
+
cursor: ENV["CURSOR"].presence,
|
|
29
|
+
batch_size: integer_env("BATCH_SIZE") || ActiveStorageQuota::Backfill::DEFAULT_BATCH_SIZE,
|
|
30
|
+
max_batches: integer_env("MAX_BATCHES"),
|
|
31
|
+
dry_run: dry_run,
|
|
32
|
+
allow_active_enforcement: ENV["ALLOW_ACTIVE_ENFORCEMENT"] == "1"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
report(result, record_type, dry_run)
|
|
36
|
+
rescue ActiveStorageQuota::EnforcementActive => e
|
|
37
|
+
abort <<~MESSAGE
|
|
38
|
+
#{e.message}
|
|
39
|
+
|
|
40
|
+
Backfill writes charges but not used_bytes, so counters stay incomplete
|
|
41
|
+
until reconcile_counters! runs. Enforcing against them meanwhile would
|
|
42
|
+
admit uploads that should have been refused.
|
|
43
|
+
|
|
44
|
+
Either let limits resolve to nil during adoption, or re-run with
|
|
45
|
+
ALLOW_ACTIVE_ENFORCEMENT=1 if you accept that.
|
|
46
|
+
MESSAGE
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
def require_explicit_scope!(record_type)
|
|
51
|
+
return if record_type
|
|
52
|
+
return if ENV["ALL"] == "1"
|
|
53
|
+
|
|
54
|
+
abort "Refusing to back-fill every record type. Pass RECORD_TYPE=..., or ALL=1."
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def report(result, record_type, dry_run)
|
|
58
|
+
puts result
|
|
59
|
+
puts " scope: #{record_type || 'all record types'}"
|
|
60
|
+
|
|
61
|
+
if result.next_cursor
|
|
62
|
+
puts
|
|
63
|
+
puts " Not finished. Resume with:"
|
|
64
|
+
puts " #{resume_command(result.next_cursor, record_type, dry_run)}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
return unless result.counters_require_reconciliation?
|
|
68
|
+
|
|
69
|
+
puts
|
|
70
|
+
puts " Charges were created but used_bytes was not touched."
|
|
71
|
+
puts " Run ActiveStorageQuota.reconcile_counters!(owner: ...) before enabling limits."
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Resumes exactly this run. The task is the same one -- a dry run must
|
|
75
|
+
# never hint at apply -- and so is the scope, which apply requires and
|
|
76
|
+
# which the cursor is bound to. Batching and an explicit enforcement
|
|
77
|
+
# override are carried forward only when the operator gave them.
|
|
78
|
+
def resume_command(cursor, record_type, dry_run)
|
|
79
|
+
env = [ "CURSOR=#{Shellwords.escape(cursor)}" ]
|
|
80
|
+
env << (record_type ? "RECORD_TYPE=#{Shellwords.escape(record_type)}" : "ALL=1")
|
|
81
|
+
%w[BATCH_SIZE MAX_BATCHES].each do |name|
|
|
82
|
+
env << "#{name}=#{Shellwords.escape(ENV[name])}" if ENV[name].present?
|
|
83
|
+
end
|
|
84
|
+
env << "ALLOW_ACTIVE_ENFORCEMENT=1" if ENV["ALLOW_ACTIVE_ENFORCEMENT"] == "1"
|
|
85
|
+
|
|
86
|
+
task = dry_run ? "active_storage_quota:backfill" : "active_storage_quota:backfill:apply"
|
|
87
|
+
"#{env.join(" ")} bin/rails #{task}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# A malformed value must never become zero or unlimited.
|
|
91
|
+
def integer_env(name)
|
|
92
|
+
raw = ENV[name].presence
|
|
93
|
+
return nil if raw.nil?
|
|
94
|
+
|
|
95
|
+
value = Integer(raw)
|
|
96
|
+
abort "#{name} must be a positive Integer, got #{raw.inspect}" unless value.positive?
|
|
97
|
+
|
|
98
|
+
value
|
|
99
|
+
rescue ArgumentError, TypeError
|
|
100
|
+
abort "#{name} must be a positive Integer, got #{raw.inspect}"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|