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,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
|
|
5
|
+
module ActiveStorageQuota
|
|
6
|
+
# Mixed into ActiveStorage::Blob through the :active_storage_blob load hook.
|
|
7
|
+
#
|
|
8
|
+
# This is how purging is observed. ActiveStorage::Attachment#purge deletes the
|
|
9
|
+
# attachment row with `delete`, firing no callbacks, and then destroys the
|
|
10
|
+
# blob -- and that destruction does run callbacks. So the blob is where a
|
|
11
|
+
# purge becomes visible to accounting.
|
|
12
|
+
#
|
|
13
|
+
# Idempotent against AttachmentAccounting: whichever path deletes the charge
|
|
14
|
+
# row is the one that lowers used_bytes, and the other finds nothing left.
|
|
15
|
+
#
|
|
16
|
+
# A blob shared by several attachments is not destroyed at all (Blob's own
|
|
17
|
+
# before_destroy raises InvalidForeignKey, which purge swallows), so purging
|
|
18
|
+
# one attachment of a shared blob is invisible here too. That remains a
|
|
19
|
+
# reconciliation case.
|
|
20
|
+
module BlobAccounting
|
|
21
|
+
extend ActiveSupport::Concern
|
|
22
|
+
|
|
23
|
+
included do
|
|
24
|
+
after_destroy_commit :discard_storage_quota_charges
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
# Two independent cleanups. They are isolated from each other on purpose:
|
|
29
|
+
# a failure discarding charges must not stop reservations being released,
|
|
30
|
+
# and neither may ever roll back the deletion of a user's file.
|
|
31
|
+
def discard_storage_quota_charges
|
|
32
|
+
cleanup("blob_destroy") { ActiveStorageQuota::Charge.discard_blob!(blob_id: id) }
|
|
33
|
+
cleanup("blob_destroy_reservations") do
|
|
34
|
+
ActiveStorageQuota::Reservation.release_active_for_blob!(blob_id: id)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def cleanup(operation)
|
|
39
|
+
yield
|
|
40
|
+
rescue StandardError => e
|
|
41
|
+
ActiveStorageQuota.accounting_failed(operation: operation, exception: e, blob_id: id.to_s)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveStorageQuota
|
|
4
|
+
# The accounting ledger: one row per (account, blob) meaning "this account is
|
|
5
|
+
# charged this blob's size, once".
|
|
6
|
+
#
|
|
7
|
+
# Three layers hold storage state, and they are not interchangeable:
|
|
8
|
+
#
|
|
9
|
+
# ActiveStorage::Attachment ground truth for ownership. Only a full rebuild
|
|
10
|
+
# reading live attachments can restore it.
|
|
11
|
+
# Charge canonical ledger during normal operation.
|
|
12
|
+
# Account#used_bytes denormalized aggregate of charges.
|
|
13
|
+
#
|
|
14
|
+
# The write-path invariant is:
|
|
15
|
+
#
|
|
16
|
+
# account.used_bytes == account.charges.sum(:byte_size)
|
|
17
|
+
#
|
|
18
|
+
# It is structural, not hoped for: +record!+ is the only operation that adds
|
|
19
|
+
# to used_bytes and +discard!+ the only one that subtracts, and each does so
|
|
20
|
+
# in the same transaction that inserts or deletes the charge row. The charge
|
|
21
|
+
# row is the claim -- double counting would need a duplicate row, which the
|
|
22
|
+
# unique index forbids.
|
|
23
|
+
#
|
|
24
|
+
# Charges *can* drift from attachments, because Active Storage's detach and
|
|
25
|
+
# shared-blob purge paths fire no callbacks and deletion accounting fails
|
|
26
|
+
# open. That is what reconciliation is for, and why it has two levels:
|
|
27
|
+
# charges repair used_bytes, attachments rebuild charges.
|
|
28
|
+
class Charge < Record
|
|
29
|
+
self.table_name = "active_storage_quota_charges"
|
|
30
|
+
|
|
31
|
+
# A charge can be created and destroyed underneath us by concurrent attach
|
|
32
|
+
# and detach activity. Each retry is correct; this bounds how long we keep
|
|
33
|
+
# losing before giving up.
|
|
34
|
+
MAX_ATTEMPTS = 3
|
|
35
|
+
|
|
36
|
+
belongs_to :quota_account,
|
|
37
|
+
class_name: "ActiveStorageQuota::Account",
|
|
38
|
+
inverse_of: :charges
|
|
39
|
+
|
|
40
|
+
validates :blob_id, presence: true
|
|
41
|
+
validates :byte_size, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
|
|
42
|
+
validates :attachment_count, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
|
|
43
|
+
|
|
44
|
+
class << self
|
|
45
|
+
# Records one attachment of +blob_id+ against +account+.
|
|
46
|
+
#
|
|
47
|
+
# The first attachment inserts a charge and raises used_bytes by the
|
|
48
|
+
# blob's size, consuming a matching reservation if one is held. Later
|
|
49
|
+
# attachments of the same blob to the same account only raise the
|
|
50
|
+
# reference count.
|
|
51
|
+
#
|
|
52
|
+
# Raises QuotaExceeded when the bytes do not fit, and
|
|
53
|
+
# ConcurrentAccountingConflict when contention outlasts MAX_ATTEMPTS.
|
|
54
|
+
def record!(account:, blob_id:, byte_size:, limit_bytes:)
|
|
55
|
+
blob_id = blob_id.to_s
|
|
56
|
+
|
|
57
|
+
MAX_ATTEMPTS.times do
|
|
58
|
+
outcome = attempt_record(account, blob_id, byte_size, limit_bytes)
|
|
59
|
+
|
|
60
|
+
return outcome unless outcome == :contended
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
raise ConcurrentAccountingConflict,
|
|
64
|
+
"could not record a charge for account #{account.id} and blob " \
|
|
65
|
+
"#{blob_id} after #{MAX_ATTEMPTS} attempts; the charge is being " \
|
|
66
|
+
"created and destroyed concurrently"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Records the removal of one attachment.
|
|
70
|
+
#
|
|
71
|
+
# Returns true only when this call removed the last attachment and so
|
|
72
|
+
# lowered used_bytes.
|
|
73
|
+
def discard!(quota_account_id:, blob_id:)
|
|
74
|
+
blob_id = blob_id.to_s
|
|
75
|
+
|
|
76
|
+
transaction(requires_new: true) do
|
|
77
|
+
charge = find_by(quota_account_id: quota_account_id, blob_id: blob_id)
|
|
78
|
+
|
|
79
|
+
if charge.nil?
|
|
80
|
+
false
|
|
81
|
+
else
|
|
82
|
+
discard_one(quota_account_id, blob_id, charge.byte_size)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Re-points one of +account+'s attachments from one blob to another, as
|
|
88
|
+
# ActiveStorage::Attachment#update!(blob: other) does. Internal: called
|
|
89
|
+
# from the attachment's after_update, inside its transaction.
|
|
90
|
+
#
|
|
91
|
+
# One transaction, so a refusal leaves both blobs exactly as they were.
|
|
92
|
+
# The old reference is dropped before the new one is claimed, so the bytes
|
|
93
|
+
# an in-place replacement frees can make room for it.
|
|
94
|
+
#
|
|
95
|
+
# Every other accounting path locks the charge row and then the account,
|
|
96
|
+
# so this takes both charge rows first -- in blob_id order, so two moves in
|
|
97
|
+
# opposite directions queue rather than deadlock -- and only then touches
|
|
98
|
+
# the account. A charge that does not exist yet cannot be locked, so the
|
|
99
|
+
# new blob's row is inserted provisionally, carrying no usage, to hold it.
|
|
100
|
+
def move!(account:, from_blob_id:, to_blob_id:, byte_size:, limit_bytes:) # :nodoc:
|
|
101
|
+
from = from_blob_id.to_s
|
|
102
|
+
to = to_blob_id.to_s
|
|
103
|
+
|
|
104
|
+
transaction(requires_new: true) do
|
|
105
|
+
old_charge = nil
|
|
106
|
+
new_charge = nil
|
|
107
|
+
provisional = false
|
|
108
|
+
|
|
109
|
+
[ from, to ].sort.each do |blob_id|
|
|
110
|
+
if blob_id == from
|
|
111
|
+
old_charge = lock_pair(account.id, from)
|
|
112
|
+
else
|
|
113
|
+
new_charge, provisional = claim_pair(account.id, to, byte_size)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
discard_one(account.id, from, old_charge.byte_size) if old_charge
|
|
118
|
+
|
|
119
|
+
if provisional
|
|
120
|
+
where(id: new_charge.id).update_all([ "attachment_count = 1, updated_at = ?", Time.current ])
|
|
121
|
+
claim_usage!(account, new_charge, limit_bytes)
|
|
122
|
+
else
|
|
123
|
+
where(id: new_charge.id)
|
|
124
|
+
.update_all([ "attachment_count = attachment_count + 1, updated_at = ?", Time.current ])
|
|
125
|
+
# Already charged, so a hold taken for this blob bought nothing.
|
|
126
|
+
Reservation.release_matching!(quota_account_id: account.id, blob_id: to)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Removes every charge for a blob that no longer exists, across all
|
|
132
|
+
# accounts. Each account is settled in its own transaction so one failure
|
|
133
|
+
# cannot strand the others.
|
|
134
|
+
#
|
|
135
|
+
# Idempotent against +discard!+: whichever path deletes the row is the one
|
|
136
|
+
# that lowers used_bytes.
|
|
137
|
+
def discard_blob!(blob_id:)
|
|
138
|
+
rows = where(blob_id: blob_id.to_s).pluck(:id, :quota_account_id, :byte_size)
|
|
139
|
+
|
|
140
|
+
rows.count do |id, account_id, bytes|
|
|
141
|
+
transaction(requires_new: true) do
|
|
142
|
+
if where(id: id).delete_all == 1
|
|
143
|
+
Account.release_used_bytes!(account_id, bytes)
|
|
144
|
+
true
|
|
145
|
+
else
|
|
146
|
+
false
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Repairs one (account, blob) pair to match live Active Storage ownership.
|
|
153
|
+
#
|
|
154
|
+
# Deliberately NOT built on record!. Normal accounting is delta-based --
|
|
155
|
+
# one attach means attachment_count += 1 -- while repair is absolute:
|
|
156
|
+
# attachment_count becomes the live count, whatever it was before. Calling
|
|
157
|
+
# record! here would add one on every run and make reconciliation
|
|
158
|
+
# non-idempotent while amplifying the drift it is meant to remove.
|
|
159
|
+
#
|
|
160
|
+
# The block recounts live attachments and is called only *after* this
|
|
161
|
+
# transaction owns the charge row, which is what closes the race against a
|
|
162
|
+
# concurrent detach (see the provisional insert below).
|
|
163
|
+
#
|
|
164
|
+
# Returns :created, :removed, :count_corrected, :deletion_skipped or :noop.
|
|
165
|
+
def reconcile_pair!(quota_account_id:, blob_id:, blob_byte_size:, allow_deletion:, &recount)
|
|
166
|
+
blob_id = blob_id.to_s
|
|
167
|
+
|
|
168
|
+
transaction(requires_new: true) do
|
|
169
|
+
charge = lock_pair(quota_account_id, blob_id)
|
|
170
|
+
|
|
171
|
+
if charge
|
|
172
|
+
repair_existing(charge, allow_deletion, &recount)
|
|
173
|
+
elsif blob_byte_size.nil?
|
|
174
|
+
# No charge and no blob to size one from: nothing to create.
|
|
175
|
+
:noop
|
|
176
|
+
else
|
|
177
|
+
create_if_still_needed(quota_account_id, blob_id, blob_byte_size, allow_deletion, &recount)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# What reconcile_pair! would do, without locks or writes. Shares the
|
|
183
|
+
# decision table with the real thing so a plan cannot drift from the
|
|
184
|
+
# repair it predicts.
|
|
185
|
+
def plan_pair(quota_account_id:, blob_id:, allow_deletion:, expected_count:)
|
|
186
|
+
charge = find_by(quota_account_id: quota_account_id, blob_id: blob_id.to_s)
|
|
187
|
+
|
|
188
|
+
decide(
|
|
189
|
+
charge_present: !charge.nil?,
|
|
190
|
+
charge_count: charge&.attachment_count,
|
|
191
|
+
expected_count: expected_count,
|
|
192
|
+
allow_deletion: allow_deletion
|
|
193
|
+
)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# The single decision table, used by both apply and dry run.
|
|
197
|
+
def decide(charge_present:, charge_count:, expected_count:, allow_deletion:)
|
|
198
|
+
if !charge_present
|
|
199
|
+
expected_count.positive? ? :created : :noop
|
|
200
|
+
elsif expected_count.zero?
|
|
201
|
+
allow_deletion ? :removed : :deletion_skipped
|
|
202
|
+
elsif charge_count != expected_count
|
|
203
|
+
:count_corrected
|
|
204
|
+
else
|
|
205
|
+
:noop
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
private
|
|
210
|
+
def lock_pair(quota_account_id, blob_id)
|
|
211
|
+
lock.find_by(quota_account_id: quota_account_id, blob_id: blob_id)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def repair_existing(charge, allow_deletion, &recount)
|
|
215
|
+
expected = recount.call
|
|
216
|
+
|
|
217
|
+
case decide(charge_present: true, charge_count: charge.attachment_count,
|
|
218
|
+
expected_count: expected, allow_deletion: allow_deletion)
|
|
219
|
+
when :removed
|
|
220
|
+
# We hold the row lock, so nothing else can have taken it.
|
|
221
|
+
unless where(id: charge.id).delete_all == 1
|
|
222
|
+
raise CounterInvariantViolation,
|
|
223
|
+
"charge #{charge.id} vanished while this transaction held its lock"
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
Account.release_used_bytes!(charge.quota_account_id, charge.byte_size)
|
|
227
|
+
:removed
|
|
228
|
+
when :count_corrected
|
|
229
|
+
where(id: charge.id)
|
|
230
|
+
.update_all([ "attachment_count = ?, updated_at = ?", expected, Time.current ])
|
|
231
|
+
:count_corrected
|
|
232
|
+
when :deletion_skipped
|
|
233
|
+
:deletion_skipped
|
|
234
|
+
else
|
|
235
|
+
:noop
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# A charge that does not exist cannot be locked, so we create a
|
|
240
|
+
# provisional row carrying no usage, and only then read the live
|
|
241
|
+
# attachment state. Because the row is ours until commit, a concurrent
|
|
242
|
+
# detach either lands before the recount (and we drop the provisional)
|
|
243
|
+
# or blocks behind us (and adjusts the finished charge afterwards).
|
|
244
|
+
# Reading attachments *before* owning a row would let a deletion slip
|
|
245
|
+
# between the read and the insert, leaving a charge nothing backs.
|
|
246
|
+
def create_if_still_needed(quota_account_id, blob_id, blob_byte_size, allow_deletion, &recount)
|
|
247
|
+
charge = insert_provisional(quota_account_id, blob_id, blob_byte_size)
|
|
248
|
+
|
|
249
|
+
if charge.nil?
|
|
250
|
+
# Live accounting won the insert. Take the winner under lock and
|
|
251
|
+
# treat it as an existing pair; never increment for our own sake.
|
|
252
|
+
winner = lock_pair(quota_account_id, blob_id)
|
|
253
|
+
return :noop if winner.nil?
|
|
254
|
+
|
|
255
|
+
return repair_existing(winner, allow_deletion, &recount)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
expected = recount.call
|
|
259
|
+
|
|
260
|
+
if expected.zero?
|
|
261
|
+
# Nothing to charge after all. The provisional never carried usage,
|
|
262
|
+
# so removing it is not a destructive repair.
|
|
263
|
+
where(id: charge.id).delete_all
|
|
264
|
+
:noop
|
|
265
|
+
else
|
|
266
|
+
where(id: charge.id)
|
|
267
|
+
.update_all([ "attachment_count = ?, updated_at = ?", expected, Time.current ])
|
|
268
|
+
claim_usage_for_repair!(charge)
|
|
269
|
+
:created
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# The charge row for a move's new blob, held by this transaction: the
|
|
274
|
+
# existing one under lock, or a provisional one we inserted. Returns
|
|
275
|
+
# [charge, provisional?]. Bounded like record!, for the same reason.
|
|
276
|
+
def claim_pair(quota_account_id, blob_id, byte_size)
|
|
277
|
+
MAX_ATTEMPTS.times do
|
|
278
|
+
inserted = insert_provisional(quota_account_id, blob_id, byte_size)
|
|
279
|
+
return [ inserted, true ] if inserted
|
|
280
|
+
|
|
281
|
+
existing = lock_pair(quota_account_id, blob_id)
|
|
282
|
+
return [ existing, false ] if existing
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
raise ConcurrentAccountingConflict,
|
|
286
|
+
"could not move an attachment onto blob #{blob_id} for account " \
|
|
287
|
+
"#{quota_account_id} after #{MAX_ATTEMPTS} attempts; its charge is " \
|
|
288
|
+
"being created and destroyed concurrently"
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def insert_provisional(quota_account_id, blob_id, blob_byte_size)
|
|
292
|
+
transaction(requires_new: true) do
|
|
293
|
+
create!(quota_account_id: quota_account_id, blob_id: blob_id,
|
|
294
|
+
byte_size: blob_byte_size, attachment_count: 0)
|
|
295
|
+
end
|
|
296
|
+
rescue ActiveRecord::RecordNotUnique
|
|
297
|
+
nil
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# Repair is truth restoration, not admission control, so capacity is
|
|
301
|
+
# claimed with a nil limit: an owner whose files moved to them is
|
|
302
|
+
# charged even when that leaves them over quota. Future uploads are
|
|
303
|
+
# blocked normally by the usual guarded claim.
|
|
304
|
+
def claim_usage_for_repair!(charge)
|
|
305
|
+
return if Reservation.consume_matching!(charge: charge)
|
|
306
|
+
|
|
307
|
+
Account.find(charge.quota_account_id)
|
|
308
|
+
.claim_used_capacity!(charge.byte_size, nil)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def attempt_record(account, blob_id, byte_size, limit_bytes)
|
|
312
|
+
transaction(requires_new: true) do
|
|
313
|
+
charge = insert_charge(account, blob_id, byte_size)
|
|
314
|
+
|
|
315
|
+
if charge
|
|
316
|
+
claim_usage!(account, charge, limit_bytes)
|
|
317
|
+
charge
|
|
318
|
+
else
|
|
319
|
+
add_attachment(account, blob_id)
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# The INSERT is the claim. Only the transaction that wins it may raise
|
|
325
|
+
# used_bytes. A savepoint because on PostgreSQL a constraint violation
|
|
326
|
+
# poisons the enclosing transaction, and this one is nested.
|
|
327
|
+
def insert_charge(account, blob_id, byte_size)
|
|
328
|
+
transaction(requires_new: true) do
|
|
329
|
+
create!(
|
|
330
|
+
quota_account_id: account.id,
|
|
331
|
+
blob_id: blob_id,
|
|
332
|
+
byte_size: byte_size,
|
|
333
|
+
attachment_count: 1
|
|
334
|
+
)
|
|
335
|
+
end
|
|
336
|
+
rescue ActiveRecord::RecordNotUnique
|
|
337
|
+
nil
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def add_attachment(account, blob_id)
|
|
341
|
+
affected = where(quota_account_id: account.id, blob_id: blob_id)
|
|
342
|
+
.update_all([ "attachment_count = attachment_count + 1, updated_at = ?", Time.current ])
|
|
343
|
+
|
|
344
|
+
# A concurrent final detach deleted the row between our INSERT losing
|
|
345
|
+
# and this UPDATE running. Start over.
|
|
346
|
+
return :contended if affected.zero?
|
|
347
|
+
|
|
348
|
+
# These bytes were already charged, so any hold we took bought nothing.
|
|
349
|
+
Reservation.release_matching!(quota_account_id: account.id, blob_id: blob_id)
|
|
350
|
+
|
|
351
|
+
find_by(quota_account_id: account.id, blob_id: blob_id)
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# used_bytes must rise by exactly the blob's size, once. A held
|
|
355
|
+
# reservation supplies that capacity; otherwise it is claimed fresh.
|
|
356
|
+
def claim_usage!(account, charge, limit_bytes)
|
|
357
|
+
return if Reservation.consume_matching!(charge: charge)
|
|
358
|
+
|
|
359
|
+
account.claim_used_capacity!(charge.byte_size, limit_bytes)
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# Both statements share a transaction deliberately: the UPDATE holds the
|
|
363
|
+
# row's write lock through the DELETE, so a concurrent attach cannot
|
|
364
|
+
# change the count in between. Splitting them loses attachments.
|
|
365
|
+
def discard_one(quota_account_id, blob_id, byte_size)
|
|
366
|
+
now = Time.current
|
|
367
|
+
scope = where(quota_account_id: quota_account_id, blob_id: blob_id)
|
|
368
|
+
|
|
369
|
+
scope.where("attachment_count > 0")
|
|
370
|
+
.update_all([ "attachment_count = attachment_count - 1, updated_at = ?", now ])
|
|
371
|
+
|
|
372
|
+
return false unless scope.where(attachment_count: 0).delete_all == 1
|
|
373
|
+
|
|
374
|
+
Account.release_used_bytes!(quota_account_id, byte_size, now)
|
|
375
|
+
true
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveStorageQuota
|
|
4
|
+
# Gem-wide settings. Access through ActiveStorageQuota.config or set them in
|
|
5
|
+
# an initializer:
|
|
6
|
+
#
|
|
7
|
+
# ActiveStorageQuota.configure do |config|
|
|
8
|
+
# config.reservation_ttl = 30.minutes
|
|
9
|
+
# end
|
|
10
|
+
class Configuration
|
|
11
|
+
# One hour, in seconds. Long enough for a slow client to finish a large
|
|
12
|
+
# direct upload, short enough that a crashed process does not hold quota
|
|
13
|
+
# for the rest of the day.
|
|
14
|
+
DEFAULT_RESERVATION_TTL = 3_600
|
|
15
|
+
|
|
16
|
+
# How long a reservation stays committable, as an Integer number of seconds.
|
|
17
|
+
#
|
|
18
|
+
# Assigning an ActiveSupport::Duration is supported and normalised, so
|
|
19
|
+
# +config.reservation_ttl = 1.hour+ reads back as +3600+. Every byte and
|
|
20
|
+
# duration this gem exposes is an Integer.
|
|
21
|
+
attr_reader :reservation_ttl
|
|
22
|
+
|
|
23
|
+
def initialize
|
|
24
|
+
@reservation_ttl = DEFAULT_RESERVATION_TTL
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def reservation_ttl=(value)
|
|
28
|
+
unless value.is_a?(Numeric) && value.to_i.positive?
|
|
29
|
+
raise ConfigurationError,
|
|
30
|
+
"reservation_ttl must be a positive number of seconds " \
|
|
31
|
+
"(got #{value.inspect})"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
@reservation_ttl = value.to_i
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveStorageQuota
|
|
4
|
+
# Resolves the storage limit declared by +has_storage_quota+.
|
|
5
|
+
#
|
|
6
|
+
# A declaration is one of:
|
|
7
|
+
#
|
|
8
|
+
# limit: 5.gigabytes # Integer number of bytes
|
|
9
|
+
# limit: :storage_limit_for_plan # Symbol, a method on the owner
|
|
10
|
+
# limit: ->(owner) { ... } # callable taking the owner
|
|
11
|
+
# limit: -> { ... } # callable evaluated against the owner
|
|
12
|
+
# limit: nil # unlimited
|
|
13
|
+
#
|
|
14
|
+
# Whatever the declaration, the resolved value must be an Integer of zero or
|
|
15
|
+
# more bytes, or nil for unlimited. Nothing is coerced: a limit of "5000" or
|
|
16
|
+
# 5.0 is a bug in the host application, not a number to round off, so it
|
|
17
|
+
# raises ConfigurationError rather than silently calling +to_i+.
|
|
18
|
+
#
|
|
19
|
+
# Static declarations are validated when the class body is evaluated. Symbol
|
|
20
|
+
# and callable declarations can only be validated once resolved, so they are
|
|
21
|
+
# checked on every call.
|
|
22
|
+
class Definition
|
|
23
|
+
# Arities a callable limit may declare: zero (evaluated against the owner)
|
|
24
|
+
# or one (passed the owner).
|
|
25
|
+
SUPPORTED_ARITIES = [ 0, 1 ].freeze
|
|
26
|
+
|
|
27
|
+
attr_reader :limit
|
|
28
|
+
|
|
29
|
+
def initialize(limit:)
|
|
30
|
+
@limit = limit
|
|
31
|
+
validate_declaration!
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The limit for +owner+, in bytes. Returns nil when unlimited.
|
|
35
|
+
#
|
|
36
|
+
# Raises ConfigurationError if a Symbol or callable declaration resolves to
|
|
37
|
+
# something that is not an Integer >= 0 or nil.
|
|
38
|
+
def resolve(owner)
|
|
39
|
+
validate_resolved(call_limit(owner), owner)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
def call_limit(owner)
|
|
44
|
+
case limit
|
|
45
|
+
when nil, Integer then limit
|
|
46
|
+
when Symbol then owner.public_send(limit)
|
|
47
|
+
else
|
|
48
|
+
limit.arity.zero? ? owner.instance_exec(&limit) : limit.call(owner)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def validate_declaration!
|
|
53
|
+
case limit
|
|
54
|
+
when nil then nil
|
|
55
|
+
when Integer then validate_non_negative!(limit)
|
|
56
|
+
when Symbol then nil
|
|
57
|
+
else
|
|
58
|
+
unless limit.respond_to?(:call)
|
|
59
|
+
raise ConfigurationError,
|
|
60
|
+
"has_storage_quota limit: must be an Integer number of bytes, " \
|
|
61
|
+
"nil (unlimited), a Symbol naming a method on the owner, or a " \
|
|
62
|
+
"callable; got #{limit.inspect} (#{limit.class})"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
unless SUPPORTED_ARITIES.include?(limit.arity)
|
|
66
|
+
raise ConfigurationError,
|
|
67
|
+
"has_storage_quota limit: a callable must take no arguments " \
|
|
68
|
+
"(evaluated against the owner) or one argument (the owner); " \
|
|
69
|
+
"got arity #{limit.arity}"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def validate_non_negative!(bytes)
|
|
75
|
+
return bytes unless bytes.negative?
|
|
76
|
+
|
|
77
|
+
raise ConfigurationError,
|
|
78
|
+
"has_storage_quota limit: must be zero or more bytes; got #{bytes}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def validate_resolved(bytes, owner)
|
|
82
|
+
return nil if bytes.nil?
|
|
83
|
+
|
|
84
|
+
unless bytes.is_a?(Integer)
|
|
85
|
+
raise ConfigurationError,
|
|
86
|
+
"#{owner.class.name} resolved its storage limit to " \
|
|
87
|
+
"#{bytes.inspect} (#{bytes.class}); expected an Integer number " \
|
|
88
|
+
"of bytes, or nil for unlimited"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
if bytes.negative?
|
|
92
|
+
raise ConfigurationError,
|
|
93
|
+
"#{owner.class.name} resolved its storage limit to #{bytes}; " \
|
|
94
|
+
"expected zero or more bytes"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
bytes
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/engine"
|
|
4
|
+
|
|
5
|
+
module ActiveStorageQuota
|
|
6
|
+
# Makes the gem's migrations available to the host application via
|
|
7
|
+
#
|
|
8
|
+
# bin/rails active_storage_quota:install:migrations
|
|
9
|
+
#
|
|
10
|
+
# and its app/controllers autoloadable, so applications can subclass
|
|
11
|
+
# ActiveStorageQuota::DirectUploadsController without requiring anything.
|
|
12
|
+
#
|
|
13
|
+
# There is deliberately no rake_tasks block: Rails::Engine#run_tasks_blocks
|
|
14
|
+
# already loads every lib/tasks/*.rake in the engine. Loading them again here
|
|
15
|
+
# would define each task twice, and Rake *appends* actions rather than
|
|
16
|
+
# replacing them -- so every task body would run twice.
|
|
17
|
+
#
|
|
18
|
+
# The engine is otherwise thin: no routes, no controllers to mount, nothing to
|
|
19
|
+
# isolate. Applications opt into the direct-upload endpoint by subclassing and
|
|
20
|
+
# routing it themselves.
|
|
21
|
+
class Engine < ::Rails::Engine
|
|
22
|
+
# Without this, Rails derives the namespace from the class name and the
|
|
23
|
+
# migration task becomes active_storage_quota_engine:install:migrations,
|
|
24
|
+
# which is both undocumented and inconsistent with every other task this
|
|
25
|
+
# gem ships under active_storage_quota:.
|
|
26
|
+
railtie_name "active_storage_quota"
|
|
27
|
+
end
|
|
28
|
+
end
|