Fix null constraint violation in project relation export uploads during direct transfer
What does this MR do?
Fixes a PG::NotNullViolation error that occurs during direct transfer when the RelationExportWorker is retried after a failed upload attempt.
Related issues
Closes #581705 (closed)
Problem
Direct transfer fails with the following error:
PG::NotNullViolation: ERROR: null value in column "project_relation_export_id" of relation "project_relation_export_uploads" violates not-null constraint
Manual export using the documented steps works correctly because it executes synchronously without retries.
Root Cause
The bug occurs on worker retry, not the first attempt:
-
First attempt:
relation_export.build_uploadcreates an upload record successfully - Upload fails: Network issue, timeout, or other error during file upload
- Worker retries: Sidekiq retries the job (up to 6 times)
-
Second attempt:
relation_export.build_uploadis called again -
Rails behavior: When building a new
has_oneassociation, Rails tries to nullify the existing upload'sproject_relation_export_idto replace it - Constraint violation: The NOT NULL constraint prevents this nullification
Solution
This MR implements an efficient fix for retry scenarios:
Instead of destroying and recreating (DELETE + INSERT):
relation_export.upload&.destroy # ❌ Inefficient
upload = relation_export.build_upload
We reuse the existing record (single UPDATE or INSERT):
upload = relation_export.upload || relation_export.build_upload # ✅ Efficient
Benefits of this approach:
- Performance: Single UPDATE operation instead of DELETE + INSERT
- Data integrity: Preserves upload ID and audit trail (created_at timestamp)
- Simplicity: Cleaner, more idiomatic Rails code
- Database load: Fewer operations, less lock contention
Changes
-
Model (RelationExportUpload): Add
optional: falsetobelongs_to :relation_export - Service: Reuse existing upload record on retry instead of destroying it
- Specs: Verify retry scenario reuses the same record
Database Impact
Query on first attempt (no existing upload):
INSERT INTO "project_relation_export_uploads"
(project_relation_export_id, export_file, created_at, updated_at)
VALUES ($1, $2, $3, $4)
Query on retry (existing upload):
UPDATE "project_relation_export_uploads"
SET export_file = $1, updated_at = $2
WHERE id = $3
Impact: Minimal - single-row operations using primary key, only on retry scenarios (rare).
Testing
- Added spec to verify retry scenario: existing upload is reused and updated successfully
- Added spec to verify
relation_exportassociation is required - Added spec to verify
project_relation_export_idis properly set - Existing specs continue to pass
Verification in Production
You can test this in Rails console:
# Create test export
relation_export = Projects::ImportExport::RelationExport.create!(...)
service = Projects::ImportExport::RelationExportService.new(relation_export, user, jid)
# First attempt
service.execute
upload_id_1 = relation_export.upload.id
# Simulate retry
relation_export.update!(status: 0, jid: nil)
service.execute
upload_id_2 = relation_export.upload.id
# Verify same record was reused
upload_id_1 == upload_id_2 # => true ✅
Checklist
-
Code changes -
Tests added/updated -
Changelog entry added (using Git trailer) -
Database query information provided -
Optimized for performance (reuse vs destroy) -
Documentation updated (if needed) -
Reviewed by maintainer
Edited by David Wainaina