Rails 8, Direct Upload, S3 - error attaching during create. No touchy touchy

I’m using ActiveStorage::DirectUploadsController to get an id and upload url Then creating a record via API

it’s a video_edit which has_one_attached :video

my Rails 7 code was the following:

  def create
    @video_edit = VideoEdit.new(video_edit_params)
    
    if @video_edit.save
      render :show, status: :created
    else
      render json: {errors: @video_edit.errors}, status: :unprocessable_content
    end
  rescue ActiveSupport::MessageVerifier::InvalidSignature
    # Handle invalid blob ID
    render json: { errors: ["Invalid attachment signature"] }, status: :unprocessable_entity
  end

in Rails 8, this fails with

ActiveRecord::ActiveRecordError: Cannot touch on a new or destroyed record object. Consider using persisted?, new_record?, or destroyed? before touching.

the problem seems to be with attach updating metadata and triggering an error:

If I break it down (thank you chatGPT)

  def create
    logger.info "🔍 Starting VideoEditsController#create"
    
    # Create the video edit without the attachment
    @video_edit = VideoEdit.new(video_edit_params.except(:video))
    logger.info "🔍 Created VideoEdit instance in memory, before save"
    
    # Save the record first
    if @video_edit.save
      logger.info "🔍 VideoEdit saved successfully with ID: #{@video_edit.id}"
      
      # Now try to attach the video
      if video_edit_params[:video].present?
        logger.info "🔍 Video parameter is present, attempting to find blob"
        
        begin
          # Find the blob first
          blob = ActiveStorage::Blob.find_signed(video_edit_params[:video])
          logger.info "🔍 Found blob with ID: #{blob.id}, key: #{blob.key}, content_type: #{blob.content_type}"
          
          # Check if record is properly persisted
          logger.info "🔍 VideoEdit persisted?: #{@video_edit.persisted?}, new_record?: #{@video_edit.new_record?}, destroyed?: #{@video_edit.destroyed?}"
          
          # Try to attach with more detailed logging
          logger.info "🔍 About to attach blob to VideoEdit"
          attachment = nil
          
          # Try with manual SQL transaction
          ActiveRecord::Base.transaction do
            logger.info "🔍 Inside transaction - before attachment"
            
            # Use the normal attach method to see where it fails
            logger.info "🔍 About to call attach method"
            @video_edit.video.attach(blob)
            logger.info "🔍 Attach method completed successfully"
            
            logger.info "🔍 After attachment save attempt"
          end
          
          logger.info "🔍 After transaction - attachment creation completed"
          
          render :show, status: :created
        rescue ActiveRecord::ActiveRecordError => e
          logger.error "🔍 ActiveRecord error during attachment: #{e.class.name}: #{e.message}"
          logger.error "🔍 Error backtrace: #{e.backtrace[0..5].join("\n")}"
          @video_edit.destroy
          render json: {errors: {base: [e.message]}}, status: :unprocessable_content
        rescue StandardError => e
          logger.error "🔍 General error during attachment: #{e.class.name}: #{e.message}"
          logger.error "🔍 Error backtrace: #{e.backtrace[0..5].join("\n")}"
          @video_edit.destroy
          render json: {errors: {base: [e.message]}}, status: :unprocessable_content
        end
      else
        logger.info "🔍 No video parameter present, skipping attachment"
        render :show, status: :created
      end
    else
      logger.error "🔍 Failed to save VideoEdit: #{@video_edit.errors.full_messages.join(', ')}"
      render json: {errors: @video_edit.errors}, status: :unprocessable_content
    end
  rescue ActiveSupport::MessageVerifier::InvalidSignature => e
    logger.error "🔍 Invalid signature error: #{e.message}"
    # Handle invalid blob ID
    render json: { errors: ["Invalid attachment signature"] }, status: :unprocessable_entity
  rescue StandardError => e
    logger.error "🔍 Unexpected error in create: #{e.class.name}: #{e.message}"
    logger.error "🔍 Error backtrace: #{e.backtrace[0..5].join("\n")}"
    render json: { errors: ["An unexpected error occurred"] }, status: :internal_server_error
  end

this fails at the attach:

web               | 🔍 About to call attach method
web               |   TRANSACTION (2.4ms)  BEGIN /*action='create',application='JumpstartApp',controller='video_edits'*/
web               |   ActiveStorage::Blob Load (7.7ms)  SELECT "active_storage_blobs".* FROM "active_storage_blobs" INNER JOIN "active_storage_attachments" ON "active_storage_blobs"."id" = "active_storage_attachments"."blob_id" WHERE "active_storage_attachments"."record_id" = 594 AND "active_storage_attachments"."record_type" = 'VideoEdit' AND "active_storage_attachments"."name" = 'video' LIMIT 1 /*action='create',application='JumpstartApp',controller='video_edits'*/
web               |   ActiveStorage::Attachment Load (2.4ms)  SELECT "active_storage_attachments".* FROM "active_storage_attachments" WHERE "active_storage_attachments"."record_id" = 594 AND "active_storage_attachments"."record_type" = 'VideoEdit' AND "active_storage_attachments"."name" = 'video' LIMIT 1 /*action='create',application='JumpstartApp',controller='video_edits'*/
web               |   ActiveStorage::Blob Update (2.5ms)  UPDATE "active_storage_blobs" SET "metadata" = '{"identified":true}' WHERE "active_storage_blobs"."id" = 33154 /*action='create',application='JumpstartApp',controller='video_edits'*/
web               |   ActiveStorage::Attachment Load (2.8ms)  SELECT "active_storage_attachments".* FROM "active_storage_attachments" WHERE "active_storage_attachments"."blob_id" = 33154 /*action='create',application='JumpstartApp',controller='video_edits'*/
web               |   TRANSACTION (1.4ms)  ROLLBACK /*action='create',application='JumpstartApp',controller='video_edits'*/
web               | 🔍 ActiveRecord error during attachment: ActiveRecord::ActiveRecordError: Cannot touch on a new or destroyed record object. Consider using persisted?, new_record?, or destroyed? before touching.
web               | 🔍 Error backtrace: /Users/rob/.rvm/gems/ruby-3.4.2/gems/activerecord-8.0.2/lib/active_record/persistence.rb:962:in 'ActiveRecord::Persistence#_raise_record_not_touched_error'
web               | /Users/rob/.rvm/gems/ruby-3.4.2/gems/activerecord-8.0.2/lib/active_record/persistence.rb:794:in 'ActiveRecord::Persistence#touch'
web               | /Users/rob/.rvm/gems/ruby-3.4.2/gems/activerecord-8.0.2/lib/active_record/callbacks.rb:432:in 'block in ActiveRecord::Callbacks#touch'
web               | /Users/rob/.rvm/gems/ruby-3.4.2/gems/activesupport-8.0.2/lib/active_support/callbacks.rb:100:in 'ActiveSupport::Callbacks#run_callbacks'
web               | /Users/rob/.rvm/gems/ruby-3.4.2/gems/activesupport-8.0.2/lib/active_support/callbacks.rb:913:in 'ActiveRecord::Base#_run_touch_callbacks'
web               | /Users/rob/.rvm/gems/ruby-3.4.2/gems/activerecord-8.0.2/lib/active_record/callbacks.rb:432:in 'ActiveRecord::Callbacks#touch'
web               |   TRANSACTION (1.3ms)  BEGIN /*action='create',application='JumpstartApp',controller='video_edits'*/
web               |   VideoEdit Destroy (3.5ms)  DELETE FROM "video_edits" WHERE "video_edits"."id" = 594 /*action='create',application='JumpstartApp',controller='video_edits'*/
web               |   TRANSACTION (1.4ms)  COMMIT /*action='create',application='JumpstartApp',controller='video_edits'*/

Disabling touching gets me out of the hole

  def create
    @video_edit = VideoEdit.new(video_edit_params)
    
    # Disable touch operations for the entire process
    ActiveRecord::Base.no_touching do
      if @video_edit.save
        render :show, status: :created
      else
        render json: {errors: @video_edit.errors}, status: :unprocessable_content
      end
    end
  rescue ActiveSupport::MessageVerifier::InvalidSignature
    # Handle invalid blob ID
    render json: { errors: ["Invalid attachment signature"] }, status: :unprocessable_entity
  end

not sure how good an idea this is though!

My video_edit record doesn’t have any explicit callbacks

Same failure mode with a trix image attachment!

How does you VideoEdit object look before trying to save it? Could you log/ pp that one?

Hi Soren,

I don’t think it’s anything to do with the video_edit model. I just found the same thing in my whats_next model which is super-vanilla

I think this relates to storing attachments on S3. Something like the following:

  • direct upload
  • create record using uploaded id
  • this calls attach
  • which updates the metadata on the attachment {identified: true}
  • which touches the initial record
  • which hasn’t been saved yet

the whats_next class below has the same crash when I use trix to update the content with an image (trix does the direct upload dance, and causes the same behaviour)

This doesn’t happen with a local storage setup - I will set up a test shortly to confirm that s3 alone triggers it.

# == Schema Information
#
# Table name: whats_nexts
#
#  id              :bigint           not null, primary key
#  is_default      :boolean          default(FALSE), not null
#  is_template     :boolean          default(FALSE), not null
#  language        :string           not null
#  sales_video_url :string
#  created_at      :datetime         not null
#  updated_at      :datetime         not null
#  account_id      :bigint           not null
#
# Indexes
#
#  index_whats_nexts_on_account_id  (account_id)
#
# Foreign Keys
#
#  fk_rails_...  (account_id => accounts.id)
#
class WhatsNext < AccountRecord
  include VideoUrlHandler

  # == Constants ============================================================

  # == Attributes ============================================================

  # == Extensions ============================================================

  acts_as_video_url :sales_video_url, allow_nil: true
  has_rich_text :content

  # == Relationships =========================================================

  # == Validations ===========================================================

  validates :language, presence: true
  validates_uniqueness_to_tenant :language
  validate :content_or_sales_video_url_must_be_present

  # == Scopes ================================================================

  # == Callbacks =============================================================

  before_save :unset_other_defaults, if: :is_default?

  # == Class Methods =========================================================

  def self.find_for_language(language)
    where(language: language).first ||
      where(is_default: true).first ||
      all.first
  end

  # == Instance Methods ======================================================

  def language_name
    LanguageHelper::LANGUAGES[language&.to_sym] || "-Missing-"
  end

  private

  def content_or_sales_video_url_must_be_present
    if content.blank? && sales_video_url.blank?
      errors.add(:base, "Either content or sales_video_url must be present")
    end
  end

  def unset_other_defaults
    WhatsNext.where(account_id: account_id, is_default: true).where.not(id: id).update_all(is_default: false)
  end
end