ActiveStorageQuota
Race-safe storage quotas for Rails Active Storage.
The problem
Rails Active Storage stores files but does not enforce how many bytes an owner may store. The naive check is a race:
if company.storage_remaining >= file.size # two requests both read 100MB free
company.documents.attach(file) # both attach 80MB; you are over quota
endThis gem replaces check-then-act with an atomic capacity claim, so concurrent uploads across multiple processes and servers cannot overshoot a quota.
Requirements
- Ruby >= 3.1
- Active Record, Active Storage and Railties >= 7.1 and < 8.2
- CI covers Rails 7.1, 7.2, 8.0 and 8.1
- PostgreSQL is the primary production target and is where the concurrency guarantees are tested.
- SQLite is supported for non-concurrency behavior and is covered by the test suite.
- Other Active Record adapters are not currently part of the CI matrix.
Installation
gem "active_storage_quota"bundle install
bin/rails active_storage_quota:install:migrations
bin/rails db:migrateUsage
Declare who holds a quota, and which records' attachments count against it.
class Company < ApplicationRecord
has_storage_quota limit: 5.gigabytes
end
class Property < ApplicationRecord
belongs_to :company
storage_quota_owner :company
has_many_attached :photos
has_one_attached :file
endThat is the whole setup. Attaching now works exactly as it always did, except
that an upload which would exceed the quota raises
ActiveStorageQuota::QuotaExceeded and is not persisted.
property.file.attach(io: pdf, filename: "contract.pdf", content_type: "application/pdf")
property.photos.attach(io: png, filename: "front.png", content_type: "image/png")
company.storage_usage # => 1_234_567 bytes currently stored
company.storage_limit # => 5368709120 bytes, or nil when unlimited
company.storage_remaining # => bytes left, floored at 0, or nil when unlimited
company.storage_reserved # => bytes held by uploads in flight
company.storage_quota_exceeded? # => falseEvery byte value is an Integer.
What counts
-
Both attachment macros are supported.
has_many_attachedandhas_one_attachedbehave identically, and every attachment name on a model uses that model's declaredstorage_quota_owner. - The same blob attached twice to one owner is charged once. Active Storage stores one object, so the owner pays for it once; the charge is reference counted and released when the last attachment goes.
- The same blob attached by two different owners is charged to each in full. A quota is an entitlement, not a bill for physical bytes, so one tenant's usage never depends on what another tenant happens to store.
-
Soft-deleted records still count. A record hidden by its model's
default_scopestill owns its attachments, so they stay charged until the attachments themselves are removed. -
Swapping a blob in place moves the charge.
attachment.update!(blob: other)releases the old blob and admits the new one in the same transaction; if the new blob does not fit, the update is rolled back.
Dynamic and unlimited limits
limit: takes an Integer, a Symbol naming a method on the owner, a callable, or
nil.
has_storage_quota limit: :plan_storage_limit
has_storage_quota limit: ->(company) { company.subscription.storage_limit }
has_storage_quota limit: nil # unlimited: still accounted, never refusedNothing is coerced. A limit of "5000" or 5.0 raises
ActiveStorageQuota::ConfigurationError rather than being rounded off.
Reads are advisory
storage_usage, storage_remaining and the other read helpers describe the
quota at the instant they run. They suit UI such as a usage bar, but must not be
used as a check-then-act admission guard.
Admission is enforced by the gem's write paths — attaching a file, and issuing a direct-upload URL — where the capacity test and the claim are one atomic statement.
Direct uploads
Browser-to-storage direct uploads bypass your server, so the quota has to be checked when the browser asks for an upload URL. Subclass the gem's controller, say who pays, route to it, and point your file inputs at that route. The gem draws no routes of its own.
# app/controllers/quota_direct_uploads_controller.rb
class QuotaDirectUploadsController < ActiveStorageQuota::DirectUploadsController
include Authentication
before_action :authenticate!
private
def storage_quota_owner
Current.company
end
end# config/routes.rb
post "/direct_uploads",
to: "quota_direct_uploads#create",
as: :quota_direct_uploads<%= form.file_field :documents,
multiple: true,
data: { direct_upload_url: quota_direct_uploads_url } %>The data-direct-upload-url attribute is all @rails/activestorage looks for.
Do not also pass direct_upload: true: Rails would then add a second
data-direct-upload-url pointing at its own endpoint, which issues upload URLs
without reserving quota.
That is the whole setup. The response is Rails' own direct-upload JSON, so
stock @rails/activestorage needs no custom JavaScript beyond using this
endpoint — there is no reservation token and nothing of ours in blob metadata.
Capacity is held against the owner's quota when the URL is issued, and becomes
usage when the signed blob id is attached.
Authentication is yours
ActiveStorageQuota::DirectUploadsController inherits Active Storage's
controller, which descends from ActionController::Base — not from your
ApplicationController. Its before_action filters do not run here. Sessions,
cookies and CSRF protection all work normally.
storage_quota_owner is therefore the authorization boundary, and your
application must establish the authenticated tenant before it is trusted.
Include your own authentication concern, as above.
Returning nil from storage_quota_owner, or an object that does not declare
has_storage_quota, raises. Those are wiring mistakes, not request outcomes,
and the gem deliberately does not dress them up as a friendly 4xx.
When the quota is full
409 Conflict
{"error":"storage_quota_exceeded"}409 rather than 413: the request is well formed and small — the file is not in
it — but it conflicts with the account's current state, and the user can resolve
it by freeing space and retrying. Override quota_exceeded_status in your
subclass if you disagree. No quota figures are returned; stock
@rails/activestorage discards error bodies and surfaces only the status.
Abandoned and retried uploads
If the browser never uploads, or uploads but never attaches, the reservation is
released by ActiveStorageQuota.release_expired_reservations! and the blob by
Rails' own active_storage:purge_unattached. Two independent janitors; neither
needs the other.
A retried POST creates a second blob and a second hold, exactly as stock Active
Storage creates a second blob. Both expire. reservation_ttl controls how long
a duplicate or abandoned request holds capacity.
Destroying an unattached blob releases any holds naming it immediately, since a destroyed blob can never be attached.
Cross-database limitation
A quota refusal always rolls the blob back, because the exception propagates out of the blob's transaction — that holds whether or not the quota tables share a connection with Active Storage.
What separate databases cannot guarantee is the reverse: if the reservation commits and an Active Storage-side operation then fails, the hold outlives the blob. It is recovered by the reservation TTL. The gem does not attempt distributed transactions or compensating deletes.
A note on byte_size
byte_size originates from the direct-upload client. Whether the declared
length is actually enforced depends on the Active Storage service adapter: S3
binds it into the signed request and Disk verifies it against a signed token,
both verified. Other adapters need their own analysis. This gem does not verify
the stored object's physical size.
Maintenance
Accounting is a denormalized ledger, so the gem ships tools to inspect and repair it. All of them are safe to run against production.
bin/rails active_storage_quota:audit # read-only; writes nothing, ever
ALL=1 bin/rails active_storage_quota:reconcile # always a dry run; prints a plan
bin/rails active_storage_quota:release_expired_reservationsaudit reports drift without touching anything, and exits non-zero when it
finds any, so it works as a cron or CI check. Repairs are a separate, explicitly
named task:
OWNER_TYPE=Company OWNER_ID=1 bin/rails active_storage_quota:reconcile:applyBoth reconcile tasks take OWNER_TYPE and OWNER_ID for one owner, or ALL=1
for every owner, and refuse to run given neither.
Removing charges that nothing backs any more is destructive, so it only happens
when you supply a budget (MAX_CHARGE_DELETIONS and MAX_REMOVED_FRACTION on
the rake tasks), and a circuit breaker stops a run that would remove more than
you allowed:
ActiveStorageQuota.reconcile!(owner: company,
max_charge_deletions: 10,
max_removed_fraction: 0.25)Reservations for uploads that were started and abandoned are released by
release_expired_reservations; run it from cron, a recurring job, or whatever
scheduler you already have. The gem does not ship one, and needs no Redis or
Sidekiq.
Adopting active_storage_quota in an existing application
An application that already stores files has historical attachments the gem knows nothing about. Backfill builds the initial ledger for them.
The order matters. Backfill writes charges but deliberately does not write
used_bytes, so counters understate the ledger until counter reconciliation
runs. Enforcing quotas against incomplete counters would admit uploads that
should have been refused, so enforcement stays off until the end.
1. Install and run the migrations.
2. Declare the macros:
class Company < ApplicationRecord
has_storage_quota limit: ->(company) {
Adoption.enforcing?(company) ? company.plan_limit : nil
}
end
class Contract < ApplicationRecord
belongs_to :company
storage_quota_owner :company
has_many_attached :documents
end
3. Deploy with limits resolving to nil.
Accounting is now active for new uploads; enforcement is not.
4. See what backfill would do:
bin/rails active_storage_quota:backfill
5. Create the historical charges, repeating until complete:
ALL=1 bin/rails active_storage_quota:backfill:apply
CURSOR=<printed cursor> ALL=1 bin/rails active_storage_quota:backfill:apply
6. Establish the counters from the finished ledger:
ActiveStorageQuota.reconcile_counters!(owner: company)
7. Check the result:
bin/rails active_storage_quota:audit
8. Repair anything the audit flags as repairable:
ActiveStorageQuota.reconcile!(owner: company)
9. Resolve the report-only findings by hand: missing or invalid quota owners,
unresolvable record types, byte size mismatches.
10. Switch limits to their real values, one tenant at a time.
11. bin/rails active_storage_quota:audit # expect it clean
backfill:apply refuses to start while any owner still has a real limit,
precisely because step 6 has not happened yet. Pass
ALLOW_ACTIVE_ENFORCEMENT=1 only if you understand that consequence.
The one exception to the ledger invariant
Normal operation always holds:
account.used_bytes == account.charges.sum(:byte_size)Backfill is the single sanctioned exception: between step 5 and step 6 charges
exist that used_bytes does not yet reflect. This is safe only while limits
resolve to nil, and step 6 closes it.
Troubleshooting
Rails 8.1 and json 3.x
Some Rails 8.1 releases have a compatibility problem between ActiveSupport's
JSON decoding, which Active Storage uses for blob metadata, and json 3.x.
If Active Storage operations fail with an error like:
wrong number of arguments (given 2, expected 1)
pin the host application to:
gem "json", "< 3"This is an upstream Rails/json incompatibility, not something
active_storage_quota causes or can fix, and it does not affect every Rails 8.1
installation.
Known limitations
-
detachis not observed. Active Storage'sdetachandpurgepaths delete attachment rows without firing callbacks, so adetachleaves a stale charge.bin/rails active_storage_quota:auditreports it and reconciliation repairs it. Purging is observed through the blob's destruction. Moving an attachment to a different record (attachment.update!(record: other)) is not observed either, and is repaired the same way. -
Replacing a
has_one_attachedfile can briefly count both files. Active Storage creates the replacement attachment before removing the previous one, so if the two files together exceed the remaining quota the replacement is refused, even when the new file alone would fit. The previous file stays attached and charged. - Variants and previews are not counted. Generated variant and preview storage is not represented as an attachment owned by your quota-bearing application record, so those generated bytes are not included in quota usage.
- Cross-database accounting is not atomic. When the quota tables live on a different connection from Active Storage, Rails cannot make the two writes one transaction; accounting becomes eventually consistent and audit/reconciliation are the guarantee.
-
Direct-upload
byte_sizecomes from the client. Whether the declared length is enforced depends on the storage adapter: S3 binds it into the signed request and Disk verifies it against a signed token. Other adapters need their own analysis.
Development
bin/setup # or: bundle install
bundle exec rspec
bundle exec rubocopAgainst a specific Rails version:
BUNDLE_GEMFILE=gemfiles/rails_7.1.gemfile bundle install
BUNDLE_GEMFILE=gemfiles/rails_7.1.gemfile bundle exec rspecConcurrency specs require PostgreSQL and are skipped on other adapters:
DATABASE_URL=postgres://localhost/asq_test bundle exec rspecLicense
MIT. See LICENSE.txt.