Skip to content

[ActiveStorage for All] Optional Active Storage backend for staged uploads#7555

Draft
orangewolf wants to merge 3 commits into
mainfrom
active-storage-for-all/uploaded-file-staging
Draft

[ActiveStorage for All] Optional Active Storage backend for staged uploads#7555
orangewolf wants to merge 3 commits into
mainfrom
active-storage-for-all/uploaded-file-staging

Conversation

@orangewolf

Copy link
Copy Markdown
Member

Summary

Adds an opt-in Active Storage backend for staged uploads (Hyrax::UploadedFile), preserving the existing chunked upload protocol. Part of the [ActiveStorage for All] chain; stacked on #7553 and #7554 (their commits appear in this diff until they merge).

New config:

config.uploaded_file_storage_backend = :active_storage # default :carrierwave
  • :carrierwave (default): byte-for-byte the existing behavior. ActiveFedora apps should stay here — the AF ingest path reads staged files through CarrierWave (documented in the generated initializer).
  • :active_storage: staged bytes are written through Rails Active Storage, so config/storage.yml / config.active_storage.service decide where they live — switching staging from local disk to S3 becomes that one Rails config change.

Design points:

  • Hyrax::UploadedFile gains has_one_attached :file_attachment. Reads dispatch per record (attachment present → Active Storage; CarrierWave file → CarrierWave), so records created under either backend remain readable whatever the setting — no migration required to flip.
  • The client chunk protocol is unchanged (two-step create, serial CONTENT-RANGE chunks, Cloudflare-size-safe requests; the upload widget needs no changes). Under Active Storage, Hyrax::UploadsController assembles chunks in a local staging file under config.cache_path and attaches the completed file when the final chunk arrives (last byte + 1 == total); a chunk that doesn't continue the pending assembly restarts it, mirroring the CarrierWave replacement behavior. Assembly assumes sequential chunks on one node or a shared staging path — the same constraint CarrierWave append has always had.
  • A bare String assignment (file = "name.png", the protocol's pre-create step) records the intended filename ahead of content.
  • Virus scanning runs once per newly attached content from its local source, instead of CarrierWave's rescan-of-the-growing-file on every chunk append. Unchanged-content saves don't rescan.
  • Destroying an upload purges its attachment (ActiveStorage::PurgeJob).
  • FileSetsController#uploaded_file_from_path passes the raw multipart upload straight through (both backends accept it; the CarrierWave::SanitizedFile wrap was redundant).

Dependencies

Supersedes #7549 (same change, moved from a fork branch to an in-repo branch and retitled).

Tests

Run locally against the koppie (Valkyrie) test app — dockerized Postgres/Solr/Redis:

  • spec/models/hyrax/uploaded_file_spec.rb: the storage-neutral API examples now run as shared examples against both backends, plus AS-specific coverage (intended filename, store_file, virus scan on attach, no rescan on unchanged saves, purge on destroy) — 39 examples, 0 failures
  • spec/controllers/hyrax/uploads_controller_spec.rb: legacy CarrierWave examples untouched and passing; new AS context covers pre-create, single-shot attach, sequential chunk assembly + finalize with content verification, staging-file cleanup, mismatched-chunk restart, purge on destroy — 140 examples total, 0 failures
  • spec/lib/hyrax/configuration_spec.rb (backend validation), spec/controllers/hyrax/file_sets_controller_spec.rb, spec/jobs/valkyrie_ingest_job_spec.rb, spec/services/hyrax/work_uploads_handler_spec.rb — green

AF-side (dassie) suite portions not run locally; CI covers them. RuboCop clean on touched files.

Notes for reviewers

🤖 Generated with Claude Code

orangewolf and others added 3 commits July 17, 2026 00:09
Introduces filename, content_type, byte_size, stored?, with_io, and
with_local_path on Hyrax::UploadedFile as the supported interface for
reading staged upload content, and adopts it in the storage-agnostic
consumers: ValkyrieIngestJob, WorkUploadsHandler, the uploads JSON
response, the collection branding transaction steps, and the dashboard
collections controller.

These call sites were reaching through the CarrierWave uploader
(uploader.file.to_file, uploader.filename, file_url-as-local-path) and
so were coupled to CarrierWave storing files at absolute local paths.
Routing them through one narrow API prepares for an alternative Active
Storage staging backend without behavior change today; with_io also
deterministically closes the IO that ValkyrieIngestJob previously
leaked.

ActiveFedora-specific consumers (JobIoWrapper, FileActor, FileSetActor)
intentionally keep using the CarrierWave interface unchanged.

Tests: spec/models/hyrax/uploaded_file_spec.rb (new API coverage),
spec/controllers/hyrax/uploads_controller_spec.rb,
spec/services/hyrax/work_uploads_handler_spec.rb,
spec/jobs/valkyrie_ingest_job_spec.rb,
spec/hyrax/transactions/steps/save_collection_banner_spec.rb,
spec/hyrax/transactions/steps/save_collection_logo_spec.rb,
spec/controllers/hyrax/dashboard/collections_controller_spec.rb —
all green locally against the koppie (Valkyrie) test app.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds the canonical Rails create_active_storage_tables migration to the
.dassie and .koppie test applications and regenerates their schemas,
teaches the hyrax:models generator to run active_storage:install so host
applications get the active_storage_* tables alongside Hyrax's own
migrations, and has the hyrax:config generator install a default
config/storage.yml (local disk services plus commented S3/GCS/Azure
examples) when the application does not already have one. The .dassie
sample app's storage.yml is aligned with that default (.koppie's already
matches).

Both test apps already ship config.active_storage.service settings per
environment; this fills in the missing database tables and default
service declarations so upcoming work can stage uploads and store
repository files through Active Storage, where switching local disk vs
S3 is a single Rails storage configuration change.

No behavior change: nothing in Hyrax attaches or reads Active Storage
data yet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds config.uploaded_file_storage_backend (:carrierwave by default,
:active_storage opt-in) selecting where Hyrax::UploadedFile writes new
content, and implements the Active Storage backend end to end while
leaving the CarrierWave behavior byte-for-byte unchanged.

Model: Hyrax::UploadedFile gains a file_attachment Active Storage
attachment. The storage-neutral read API now dispatches on what each
record actually carries (attachment vs CarrierWave file), so records
created under either backend stay readable whatever the setting; a bare
String assignment records the intended filename ahead of content, which
the chunked upload protocol sends first. store_file routes writes to the
configured backend. Virus scanning covers newly attached content once,
from its local source, instead of CarrierWave's rescan-on-every-save.

Controller: Hyrax::UploadsController keeps the existing two-step,
CONTENT-RANGE chunk protocol (Cloudflare-safe requests) for both
backends. Under :active_storage, chunks assemble in a local staging file
under config.cache_path and the completed file is attached on the final
chunk, sending the bytes to whatever Active Storage service is
configured; switching that service between local disk and S3 is a Rails
storage.yml change only. Destroying an upload purges its attachment.

The ActiveFedora ingest path intentionally still reads staged files
through CarrierWave; AF applications should keep the default backend
(documented in the generated initializer).

Tests: spec/models/hyrax/uploaded_file_spec.rb (shared examples run the
neutral API against both backends, plus Active Storage-specific
coverage), spec/controllers/hyrax/uploads_controller_spec.rb (pre-create,
single-shot, sequential chunk assembly + finalize, mismatch restart,
staging cleanup, purge on destroy; legacy CarrierWave examples untouched
and passing), spec/lib/hyrax/configuration_spec.rb,
spec/controllers/hyrax/file_sets_controller_spec.rb,
spec/jobs/valkyrie_ingest_job_spec.rb,
spec/services/hyrax/work_uploads_handler_spec.rb — all green locally
against the koppie (Valkyrie) test app.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Test Results

    17 files  ±    0      17 suites  ±0   1h 21m 56s ⏱️ - 2h 7m 43s
 6 085 tests  - 1 892   6 006 ✅  - 1 664   78 💤  - 229  1 ❌ +1 
19 691 runs   - 7 584  19 445 ✅  - 7 231  239 💤  - 360  3 ❌ +3  4 🔥 +4 

For more details on these failures, see this check.

Results for commit 2779d3d. ± Comparison against base commit 5cc41d6.

This pull request removes 2220 and adds 328 tests. Note that renamed tests count towards both.
spec.abilities.ability_spec ‑ Hyrax::Ability AdminSets and PermissionTemplates a user without edit access is expected not to be able to create #<Hyrax::PermissionTemplate:0x00007f37d68cb2d8>
spec.abilities.ability_spec ‑ Hyrax::Ability AdminSets and PermissionTemplates a user without edit access is expected not to be able to create #<Hyrax::PermissionTemplate:0x00007f4a43d1c448>
spec.abilities.ability_spec ‑ Hyrax::Ability AdminSets and PermissionTemplates a user without edit access is expected not to be able to create #<Hyrax::PermissionTemplate:0x00007fdf34ed9d90>
spec.abilities.ability_spec ‑ Hyrax::Ability AdminSets and PermissionTemplates a user without edit access is expected not to be able to create #<Hyrax::PermissionTemplate:0x00007fee6c78aa20>
spec.abilities.ability_spec ‑ Hyrax::Ability AdminSets and PermissionTemplates a user without edit access is expected not to be able to create #<Hyrax::PermissionTemplateAccess:0x00007f37d68c0b58>
spec.abilities.ability_spec ‑ Hyrax::Ability AdminSets and PermissionTemplates a user without edit access is expected not to be able to create #<Hyrax::PermissionTemplateAccess:0x00007f4a4ae00010>
spec.abilities.ability_spec ‑ Hyrax::Ability AdminSets and PermissionTemplates a user without edit access is expected not to be able to create #<Hyrax::PermissionTemplateAccess:0x00007fdf45ad4c48>
spec.abilities.ability_spec ‑ Hyrax::Ability AdminSets and PermissionTemplates a user without edit access is expected not to be able to create #<Hyrax::PermissionTemplateAccess:0x00007fee7038e120>
spec.abilities.ability_spec ‑ Hyrax::Ability AdminSets and PermissionTemplates a user without edit access is expected not to be able to destroy AdminSet: 2132c8ed-856c-4771-994f-282c496d9150
spec.abilities.ability_spec ‑ Hyrax::Ability AdminSets and PermissionTemplates a user without edit access is expected not to be able to destroy Hyrax::AdministrativeSet: 497eb04a-2b61-4268-a741-91b1fe06e111
…
spec.abilities.ability_spec ‑ Hyrax::Ability AdminSets and PermissionTemplates a user without edit access is expected not to be able to create #<Hyrax::PermissionTemplate:0x00007f6daf1ddc00>
spec.abilities.ability_spec ‑ Hyrax::Ability AdminSets and PermissionTemplates a user without edit access is expected not to be able to create #<Hyrax::PermissionTemplate:0x00007fc27fd2a8e0>
spec.abilities.ability_spec ‑ Hyrax::Ability AdminSets and PermissionTemplates a user without edit access is expected not to be able to create #<Hyrax::PermissionTemplate:0x00007ff7226e8360>
spec.abilities.ability_spec ‑ Hyrax::Ability AdminSets and PermissionTemplates a user without edit access is expected not to be able to create #<Hyrax::PermissionTemplateAccess:0x00007f6dbabf2e60>
spec.abilities.ability_spec ‑ Hyrax::Ability AdminSets and PermissionTemplates a user without edit access is expected not to be able to create #<Hyrax::PermissionTemplateAccess:0x00007fc27fd20160>
spec.abilities.ability_spec ‑ Hyrax::Ability AdminSets and PermissionTemplates a user without edit access is expected not to be able to create #<Hyrax::PermissionTemplateAccess:0x00007ff72b869758>
spec.abilities.ability_spec ‑ Hyrax::Ability AdminSets and PermissionTemplates a user without edit access is expected not to be able to destroy Hyrax::AdministrativeSet: 1492e929-ac12-47a4-a9ce-eb411b99273f
spec.abilities.ability_spec ‑ Hyrax::Ability AdminSets and PermissionTemplates a user without edit access is expected not to be able to destroy Hyrax::AdministrativeSet: 9402c7d2-7491-450f-be95-fef6c8242333
spec.abilities.ability_spec ‑ Hyrax::Ability AdminSets and PermissionTemplates a user without edit access is expected not to be able to destroy Hyrax::AdministrativeSet: e36b2d86-c449-4db3-a0e6-4003ed240aad
spec.abilities.ability_spec ‑ Hyrax::Ability AdminSets and PermissionTemplates a user without edit access is expected not to be able to edit Hyrax::AdministrativeSet: 33310cfa-579d-4a4e-b46c-3855aa237237
…
This pull request skips 21 tests.
spec.controllers.hyrax.dashboard.collections_controller_spec ‑ Hyrax::Dashboard::CollectionsController with model Collection #create creates a Collection with old style parameters
spec.controllers.hyrax.fixity_checks_controller_spec ‑ Hyrax::FixityChecksController when signed in POST create returns json with the result
spec.forms.hyrax.forms.collection_form_spec ‑ Hyrax::Forms::CollectionForm#permission_template when the PermissionTemplate doesn't exist gets created
spec.forms.hyrax.forms.collection_form_spec ‑ Hyrax::Forms::CollectionForm#permission_template when the PermissionTemplate exists uses the existing template
spec.forms.hyrax.forms.collection_form_spec ‑ Hyrax::Forms::CollectionForm#select_files with a work/file attached returns a hash of with file title as key and file id as value
spec.forms.hyrax.forms.resource_form_spec ‑ Hyrax::Forms::ResourceForm#version when using wings prepopulates as empty before save
spec.forms.hyrax.forms.resource_form_spec ‑ Hyrax::Forms::ResourceForm#version when using wings with a saved work prepopulates with the etag
spec.jobs.create_work_job_spec ‑ CreateWorkJob#perform with an ActiveFedora model when the actor does not create the work logs the failure
spec.jobs.create_work_job_spec ‑ CreateWorkJob#perform with an ActiveFedora model when the update is successful logs the success
spec.jobs.migrate_files_to_valkyrie_job_spec ‑ MigrateFilesToValkyrieJob it migrates all derivatives along with a file
…

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant