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,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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorageQuota
4
+ VERSION = "0.1.0"
5
+ 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