Repository navigation
🐛 Attach imported file sets in one pass - #1280
Draft
ShanaLMoore wants to merge 4 commits into
Draft
ShanaLMoore wants to merge 4 commits into
ShanaLMoore wants to merge 4 commits into
Conversation
Contributor
There was a problem hiding this comment.
🟡 Changes recommended
Critical failure paths can discard relationships needed for retries or leave orphaned file sets and uploads, while re-imports can retain visibility inconsistent with the parent work’s access policy.
3 open issues
What changed in this PR
This PR moves Valkyrie file-set attachment into Bulkrax’s relationship pass to prevent concurrent imports from overwriting work memberships.
Changes:
- Creates unattached file sets, then queues ingest after the relationship pass saves the work.
- Preserves stored child order and reschedules failed work relationship passes.
- Adds coverage for permissions, visibility inheritance, concurrent creation, and retry behavior.
| File | Description |
|---|---|
spec/support/valkyrie_memory_adapter.rb |
Adds shared in-memory Valkyrie test setup. |
spec/models/bulkrax/valkyrie_object_factory_spec.rb |
Tests unattached creation, access policies, membership, and ingest enqueueing. |
spec/models/bulkrax/csv_file_set_entry_spec.rb |
Tests pending relationships and visibility handling. |
spec/jobs/bulkrax/create_relationships_job_spec.rb |
Tests attachment order, deferred ingest, and failure recovery. |
app/models/concerns/bulkrax/file_set_entry_behavior.rb |
Records Valkyrie parent relationships and leaves blank visibility for inheritance. |
app/jobs/bulkrax/create_relationships_job.rb |
Processes children in order, defers ingest, and catches work-pass failures. |
app/factories/bulkrax/valkyrie_object_factory.rb |
Creates unattached file sets and supplies attachment and ingest helpers. |
app/factories/bulkrax/object_factory_interface.rb |
Defines factory hooks for deferred ingest. |
Review effort: Balanced
CreateRelationshipsJob takes add_child_to_parent_work's return value as the work for the rest of the pass and saves it at the end. Both object factories returned true when the child was already a member, as on a re-import or a rescheduled pass, so the pass then failed saving true. ActiveFedora's also returned the ordered_members proxy after adding a child, not the work. Both now return the work, and adding a child already a member does nothing. The Valkyrie spec runs on Valkyrie's in-memory adapter, through a shared context, so the Valkyrie path can be tested without Fedora. Ref: - notch8/hykuup_knapsack#567 Assisted by: Opus 5.5
CreateRelationshipsJob read a work's pending relationships with .ordered.find_each, but find_each always batches by primary key and ignores the order scope. Children were attached in the order their relationships were recorded, which for rows imported concurrently is the order the rows finished, not the order in the CSV. The pass now reads them with .ordered.order(:id).each, so a work's pages land in row order, and children named in one cell, which share an order, keep the order they were listed in. This changes the ActiveFedora path too, which now relates in the order `ordered` always meant. A work's relationships load at once instead of in batches; they are small rows for one parent. The collection pass keeps find_each, since collection membership has no order. Ref: - notch8/hykuup_knapsack#567 Assisted by: Opus 5.5
ShanaLMoore
force-pushed
the
i567-attach-file-sets-in-one-pass
branch
from
November 6, 2026 22:22
6dddee8 to
4f1caef
Compare
An exception from a work's relationship pass, such as saving or indexing the work, or failing to acquire its lock, escaped CreateRelationshipsJob#perform. The job has no retry_on, and adapters such as GoodJob with retry_on_unhandled_error off never run it again, so the relationships stayed pending and the work kept whatever the pass had saved. The pass now records the error as a failure, so perform sets the parent entry's status and reschedules it, as it does for a relationship that fails, up to max_failure_count. Lock errors are included on purpose; this covers the work side of #1076. Each relationship is now destroyed only once the work is saved, indexed and published, so a pass that fails before then leaves them all for the rescheduled one instead of finding nothing to do and reporting success. Re-adding a child already a member on that pass changes nothing. They are destroyed while the pass still holds the work's lock, so another pass for the same work cannot finish them too. A failed pass marks its parent's pending relationships with the error, so a stalled ScheduleRelationshipsJob leaves them to this reschedule instead of retrying them hourly, which counted as progress and kept it from ever giving up. They count as failed relationships while they wait, so the pass that links them recounts and sets the importer back to Complete. This applies to both object factories. Ref: - #1076 - notch8/hykuup_knapsack#567 Assisted by: Opus 5.5
When several file set rows for one work import at once, some file sets drop off the work. ValkyrieObjectFactory#create_file_set loaded the work outside any lock and ran Hyrax's change_set.update_work, whose first step saves the work's member_ids as loaded, erasing file sets other rows had attached meanwhile. hyrax#7240 fixed only the attach step, on 5.0-flexible, and that step runs after the stale save. On the Valkyrie path, each file set row now creates its file set without touching the work: with the work's permissions, the row's visibility, or else the work's visibility, embargo and lease, and its upload left unqueued. The factory builds the file set as Hyrax 5.3's WorkUploadsHandler does rather than calling it, since Hyrax 4's handler queues the ingest itself. The row records a pending relationship to its first parent, the one the file set is created for, skipping a parent the import deletes once #1279's deleted_identifier? is there. A row whose parent is not found, or is not a work, fails before anything is saved. A re-import of a row with no visibility gives the file set its work's current visibility, embargo and lease, as creating it does, unless the file set's own embargo or lease is still running. An ended embargo or lease on the file set is cleared first, so Hyrax's expiry jobs cannot later release it to its after-visibility, and the file set is reindexed so its indexed embargo and lease match. CreateRelationshipsJob then adds every file set to the work in one pass under the work's lock, setting the representative and thumbnail on the first as Hyrax did on attach. Once the work is saved it queues each upload's ingest once, through the object factory, and only then destroys that file set's relationship, so a pass killed or failing before that leaves it for the retry, which queues the ingest of a file set already attached without its file. An enqueue that is refused without raising is a failure. ActiveFedora is unchanged: its file sets are attached when created, its entries record no relationship, and its factory holds no ingests. Ref: - #570 - samvera/hyrax#7240 - notch8/hykuup_knapsack#567 - notch8/utk_knapsack#435 Assisted by: Opus 5.5
ShanaLMoore
force-pushed
the
i567-attach-file-sets-in-one-pass
branch
from
November 6, 2026 22:49
4f1caef to
fe6609b
Compare
Contributor
There was a problem hiding this comment.
🟡 Changes recommended
A re-import can attach a file set to a new work without removing its existing membership, violating Hyrax’s one-work-per-file-set contract and potentially changing access through visibility inheritance.
1 open issue
4 resolved since last review
Review effort: Balanced
| # try: ImportBehavior#deleted_identifier? arrives with samvera/bulkrax#1279 | ||
| return if parent_identifier.blank? || try(:deleted_identifier?, parent_identifier) | ||
|
|
||
| PendingRelationship.create!(child_id: identifier, parent_id: parent_identifier, importer_run_id: importerexporter.last_run.id, order: id) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


Refs #570
When several file set rows for one work import at once, some file sets drop off the work.
ValkyrieObjectFactory#create_file_setloads the work outside any lock and runs Hyrax'schange_set.update_work, whose first step saves the work'smember_idsas loaded, erasing file sets other rows attached meanwhile. samvera/hyrax#7240 fixed only the attach step, on 5.0-flexible, and that step runs after the stale save.🐛 Return the saved work when its child is already a member
d250437
CreateRelationshipsJobtakesadd_child_to_parent_work's return value as the work for the rest of the pass and saves it at the end. Both object factories returnedtruefor a child already a member, as on a re-import or a rescheduled pass, and ActiveFedora's returned theordered_membersproxy after adding one. Both now return the work, and re-adding a member does nothing. Adds a shared spec context that runs the Valkyrie path on Valkyrie's in-memory adapter.🐛 Relate a work's children in their stored order
8ac6276
.ordered.find_eachbatches by primary key and ignores the order scope, so a work's children were attached in the order their rows finished. The work pass now uses.ordered.order(:id).each: row order, with children named in one cell keeping their listed order. This changes the ActiveFedora path too. The collection pass keepsfind_each, since collection membership has no order.🐛 Reschedule a work's relationship pass that fails
a3677d9
An exception from a work's pass (saving, indexing or publishing the work, or acquiring its lock) escaped
perform, which most adapters never retry, so relationships stayed pending. The pass now records it as a failure, andperformreschedules it like any failed relationship. Lock errors are included on purpose, which covers the work side of #1076. Each relationship is now destroyed only once the work is saved, indexed and published, so a failed pass leaves them all for the rescheduled one rather than finding nothing to do and reporting success. They are destroyed while the pass still holds the work's lock, so a second pass for the same work cannot finish them too. A failed pass marks its parent's pending relationships with the error, so a stalledScheduleRelationshipsJobleaves them to this reschedule instead of retrying them hourly, which counted as progress and kept it from ever reachingGIVE_UP_LIMIT. Those relationships are counted as failed while they wait, so the pass that links them recounts and sets the importer back to Complete. Applies to both object factories.🐛 Attach imported file sets in one pass
fe6609b
Valkyrie path only:
create_file_setcreates the file set without touching the work or queueing its ingest, with the work's permissions and the row's visibility, or else the work's visibility, embargo and lease. It builds the file set as Hyrax 5.3'sWorkUploadsHandlerdoes instead of calling it, since Hyrax 4's handler queues the ingest itself. A row whose parent is not found, or is a collection or file set rather than a work, fails before anything is saved.Steps::Savefuture-date check; Hyrax 4.0's Valkyrie save has no such check, so this is where it applies.) A row that names a visibility, including embargo or lease with its dates, is updated by the file set form as before.deleted_identifier?, from 🐛 Validate delete rows the way import runs them #1279).CreateRelationshipsJobadds every file set in one pass under the work's lock and sets the representative and thumbnail as Hyrax did on attach. Once the work is saved, still under its lock, it queues each upload once and then destroys its relationship. A pass that is killed, fails, or has an enqueue refused leaves the relationships for the retry, which queues the ingest of a file set already attached without its file.ActiveFedora is unchanged here: its file sets are still attached when created.
Behaviour changes
Known trade-offs / for reviewers
max_failure_count, 5 tries, 10 minutes apart) rather than the adapter's retries or dead set. They show in the parent entry's status and the run'sfailed_relationships.REDIS_HOST). Preventing it needs a claim shared across jobs.file.set.attachedis no longer published for imported file sets, soFileSetAttachedEventJobactivity-stream entries stop for them.thumbnail_urlorremote_filesnow fails, since its one identifier can relate only one file set.replace_original_fileand the visibility save could lose afile_id, the same windowdetach_filesalready has.failed_relationships, so while a rescheduled pass is still to run, the importer can show Complete (with failures). The pass that links them recounts and sets it back to Complete.try(:deleted_identifier?, ...)works before and after 🐛 Validate delete rows the way import runs them #1279; it relies on 🐛 Validate delete rows the way import runs them #1279 keepingdeleted_identifier?public. Whichever merges second can make it a plain call.Testing
Refs
🤖 Assisted with Claude Code