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.

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:

  1. First attempt: relation_export.build_upload creates an upload record successfully
  2. Upload fails: Network issue, timeout, or other error during file upload
  3. Worker retries: Sidekiq retries the job (up to 6 times)
  4. Second attempt: relation_export.build_upload is called again
  5. Rails behavior: When building a new has_one association, Rails tries to nullify the existing upload's project_relation_export_id to replace it
  6. 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:

  1. Performance: Single UPDATE operation instead of DELETE + INSERT
  2. Data integrity: Preserves upload ID and audit trail (created_at timestamp)
  3. Simplicity: Cleaner, more idiomatic Rails code
  4. Database load: Fewer operations, less lock contention

Changes

  • Model (RelationExportUpload): Add optional: false to belongs_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_export association is required
  • Added spec to verify project_relation_export_id is 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

Merge request reports

Loading