Construye un Sistema de Deduplicación de Archivos con ActiveStorage

Cada vez que un usuario sube su logo de empresa, foto de perfil, o ese mismo PDF que ya ha subido tres veces antes, estás pagando por almacenarlo de nuevo. ActiveStorage no deduplica por defecto—cada carga crea un nuevo blob, incluso si el contenido es idéntico.

Vamos a arreglar eso. Construiremos un sistema de deduplicación que detecta archivos idénticos y reutiliza blobs existentes, ahorrando costos de almacenamiento y haciendo las cargas instantáneas para duplicados. Limitaremos la deduplicación a cada usuario—así los usuarios solo deduplican contra sus propias cargas, manteniendo los archivos seguros.

Cómo Funcionan los Checksums de ActiveStorage

Cada blob de ActiveStorage tiene una columna checksum, que es un hash MD5 del contenido del archivo. Dos archivos con contenido idéntico siempre tendrán el mismo checksum:

ActiveStorage::Blob.pluck(:checksum).tally
# => {"vckNNU4TN7zbzl+o3tjXPQ==" => 47, "x8K9f2mVhLpWQ..." => 12, ...}

Si ves conteos mayores a 1, tienes duplicados. Vamos a eliminarlos.

Añadiendo un Índice para Rendimiento

ActiveStorage no indexa la columna checksum por defecto. Sin un índice, las consultas de deduplicación harán escaneos completos de tabla—bien para apps pequeñas, pero lento a escala.

Nota: Esta migración modifica el esquema de ActiveStorage. Aunque añadir un índice es de bajo riesgo (sin cambios estructurales), ten en cuenta que estás personalizando una tabla propiedad de un engine. Documenta esta decisión para tu equipo.

class AddIndexToActiveStorageBlobsChecksum < ActiveRecord::Migration[8.0]
  disable_ddl_transaction!

  def change
    add_index :active_storage_blobs, :checksum, algorithm: :concurrently
  end
end

La opción algorithm: :concurrently (PostgreSQL) construye el índice sin bloquear escrituras, lo cual es esencial para bases de datos en producción con datos existentes. Requiere disable_ddl_transaction! ya que la creación de índices concurrentes no puede ejecutarse dentro de una transacción.

Paso 1: Deduplicación del Lado del Servidor

Al crear un blob, verifica si el usuario actual ya tiene uno con ese checksum.

Crea un controlador para manejar cargas directas deduplicadas:

# app/controllers/deduplicated_uploads_controller.rb
class DeduplicatedUploadsController < ActiveStorage::DirectUploadsController
  before_action :authenticate_user!

  def create
    existing_blob = find_existing_blob_for_user(blob_params[:checksum])

    if existing_blob
      render json: existing_blob_json(existing_blob)
    else
      super
    end
  end

  private

  def find_existing_blob_for_user(checksum)
    return nil if checksum.blank?

    # Solo encuentra blobs que el usuario actual ha subido antes
    # Usa una única consulta eficiente con subconsultas EXISTS
    ActiveStorage::Blob
      .joins(:attachments)
      .where(checksum: checksum)
      .where(user_owns_attachment_sql)
      .first
  end

  # Fragmento SQL que verifica si current_user es dueño del registro adjunto
  # Usa subconsultas EXISTS para eficiencia (sin cargar IDs en memoria)
  def user_owns_attachment_sql
    <<~SQL.squish
      (
        (active_storage_attachments.record_type = 'Document'
         AND EXISTS (SELECT 1 FROM documents WHERE documents.id = active_storage_attachments.record_id AND documents.user_id = #{current_user.id}))
        OR
        (active_storage_attachments.record_type = 'Avatar'
         AND EXISTS (SELECT 1 FROM avatars WHERE avatars.id = active_storage_attachments.record_id AND avatars.user_id = #{current_user.id}))
      )
    SQL
  end

  def existing_blob_json(blob)
    {
      id: blob.id,
      key: blob.key,
      filename: blob.filename.to_s,
      content_type: blob.content_type,
      byte_size: blob.byte_size,
      checksum: blob.checksum,
      signed_id: blob.signed_id,
      direct_upload: nil  # Señal para omitir la carga
    }
  end

  def blob_params
    params.require(:blob).permit(:filename, :byte_size, :checksum, :content_type, metadata: {})
  end
end

Esto consulta a través de la tabla active_storage_attachments para encontrar blobs adjuntos a registros que el usuario actual posee. Ajusta attachable_types y user_record_ids para que coincidan con el modelo de propiedad de tu aplicación.

Añade la ruta:

# config/routes.rb
Rails.application.routes.draw do
  post '/rails/active_storage/direct_uploads',
       to: 'deduplicated_uploads#create',
       as: :deduplicated_direct_uploads
end

Cómo Funciona el Adjunto

Cuando encontramos un blob existente, devolvemos su signed_id. El cliente envía este signed_id con el formulario, y ActiveStorage crea un nuevo adjunto apuntando al blob existente:

# app/controllers/documents_controller.rb
class DocumentsController < ApplicationController
  def create
    @document = current_user.documents.build(document_params)

    if @document.save
      redirect_to @document
    else
      render :new
    end
  end

  private

  def document_params
    params.require(:document).permit(:title, :file)
  end
end

Cuando params[:document][:file] es una cadena signed_id (no un archivo subido), ActiveStorage encuentra el blob y lo adjunta. Un blob puede tener muchos adjuntos:

blob = ActiveStorage::Blob.find_by(checksum: "vckNNU4TN7zbzl+o3tjXPQ==")
blob.attachments.count
# => 5  (cinco registros diferentes comparten este blob)

Esta es la clave de la deduplicación: múltiples registros referencian el mismo archivo almacenado.

Paso 2: Manejo de Carga del Lado del Cliente

El cliente necesita saber cuándo omitir la carga. Cuando direct_upload es null en la respuesta, el archivo ya existe:

// app/javascript/controllers/deduplicated_upload_controller.js
import { Controller } from "@hotwired/stimulus"
import { DirectUpload } from "@rails/activestorage"

export default class extends Controller {
  static targets = ["input", "progress"]
  static values = { url: String }

  upload() {
    const file = this.inputTarget.files[0]
    if (!file) return

    const upload = new DirectUpload(file, this.urlValue, this)

    upload.create((error, blob) => {
      if (error) {
        console.error(error)
      } else {
        this.handleSuccess(blob)
      }
    })
  }

  handleSuccess(blob) {
    // Crear input oculto con signed_id
    const input = document.createElement("input")
    input.type = "hidden"
    input.name = this.inputTarget.name
    input.value = blob.signed_id
    this.inputTarget.form.appendChild(input)

    if (blob.direct_upload === null) {
      this.showMessage("¡El archivo ya existe - carga instantánea!")
    } else {
      this.showMessage("Carga completada")
    }
  }

  // Métodos delegados de DirectUpload
  directUploadWillStoreFileWithXHR(request) {
    request.upload.addEventListener("progress", (event) => {
      const progress = (event.loaded / event.total) * 100
      this.progressTarget.style.width = `${progress}%`
    })
  }

  showMessage(text) {
    // Actualizar UI para mostrar estado
    this.progressTarget.textContent = text
  }
}

Úsalo en tu formulario:

<%= form_with model: @document do |f| %>
  <div data-controller="deduplicated-upload"
       data-deduplicated-upload-url-value="<%= deduplicated_direct_uploads_url %>">

    <%= f.file_field :file,
        data: {
          deduplicated_upload_target: "input",
          action: "change->deduplicated-upload#upload"
        } %>

    <div data-deduplicated-upload-target="progress"></div>
  </div>

  <%= f.submit %>
<% end %>

Paso 3: Omitir la Solicitud de Red Completamente

Podemos ir más allá. Calcula el checksum del lado del cliente y verifica si el blob existe antes de intentar la carga:

Extrae la lógica de alcance en un concern para compartir entre controladores:

# app/controllers/concerns/blob_scoping.rb
module BlobScoping
  extend ActiveSupport::Concern

  def find_user_blob(checksum)
    return nil if checksum.blank?

    ActiveStorage::Blob
      .joins(:attachments)
      .where(checksum: checksum)
      .where(user_owns_attachment_sql)
      .first
  end

  private

  def user_owns_attachment_sql
    <<~SQL.squish
      (
        (active_storage_attachments.record_type = 'Document'
         AND EXISTS (SELECT 1 FROM documents WHERE documents.id = active_storage_attachments.record_id AND documents.user_id = #{current_user.id}))
        OR
        (active_storage_attachments.record_type = 'Avatar'
         AND EXISTS (SELECT 1 FROM avatars WHERE avatars.id = active_storage_attachments.record_id AND avatars.user_id = #{current_user.id}))
      )
    SQL
  end
end

Luego úsalo en el controlador de búsqueda:

# app/controllers/blob_lookups_controller.rb
class BlobLookupsController < ApplicationController
  include BlobScoping
  before_action :authenticate_user!

  def show
    blob = find_user_blob(params[:checksum])

    if blob
      render json: { exists: true, signed_id: blob.signed_id }
    else
      render json: { exists: false }
    end
  end
end
# config/routes.rb
# Usa parámetro de consulta para checksum (base64 contiene +, /, = que necesitan codificación en rutas)
get '/blobs/lookup', to: 'blob_lookups#show', as: :blob_lookup

Actualiza el controlador para leer de parámetros de consulta:

# app/controllers/blob_lookups_controller.rb
def show
  blob = find_user_blob(params[:checksum])
  # ... resto sin cambios
end

Ahora el JavaScript puede verificar primero:

// app/javascript/controllers/smart_upload_controller.js
import { Controller } from "@hotwired/stimulus"
import { DirectUpload, FileChecksum } from "@rails/activestorage"

export default class extends Controller {
  static targets = ["input", "status"]
  static values = {
    lookupUrl: String,
    uploadUrl: String
  }

  async upload() {
    const file = this.inputTarget.files[0]
    if (!file) return

    try {
      this.statusTarget.textContent = "Calculando checksum..."
      const checksum = await this.computeChecksum(file)

      this.statusTarget.textContent = "Buscando duplicados..."
      const existing = await this.lookupBlob(checksum)

      if (existing.exists) {
        this.statusTarget.textContent = "¡Archivo ya subido - instantáneo!"
        this.attachSignedId(existing.signed_id)
        return
      }

      this.statusTarget.textContent = "Subiendo..."
      await this.performUpload(file)
    } catch (error) {
      this.statusTarget.textContent = `Error: ${error.message}`
      console.error("Carga fallida:", error)
    }
  }

  computeChecksum(file) {
    return new Promise((resolve, reject) => {
      FileChecksum.create(file, (error, checksum) => {
        if (error) {
          reject(new Error(`Checksum falló: ${error}`))
        } else {
          resolve(checksum)
        }
      })
    })
  }

  async lookupBlob(checksum) {
    const url = new URL(this.lookupUrlValue, window.location.origin)
    url.searchParams.set("checksum", checksum)

    const response = await fetch(url, {
      headers: {
        "X-CSRF-Token": this.csrfToken,
        "Accept": "application/json"
      },
      credentials: "same-origin"
    })

    if (!response.ok) {
      throw new Error(`Búsqueda falló: ${response.status}`)
    }

    return response.json()
  }

  get csrfToken() {
    const meta = document.querySelector('meta[name="csrf-token"]')
    return meta ? meta.content : ""
  }

  attachSignedId(signedId) {
    const input = document.createElement("input")
    input.type = "hidden"
    input.name = this.inputTarget.name
    input.value = signedId
    this.inputTarget.form.appendChild(input)
  }

  performUpload(file) {
    return new Promise((resolve, reject) => {
      const upload = new DirectUpload(file, this.uploadUrlValue, this)
      upload.create((error, blob) => {
        if (error) {
          reject(new Error(`Carga falló: ${error}`))
        } else {
          this.statusTarget.textContent = "Carga completada"
          this.attachSignedId(blob.signed_id)
          resolve(blob)
        }
      })
    })
  }

  directUploadWillStoreFileWithXHR(request) {
    request.upload.addEventListener("progress", (event) => {
      const percent = Math.round((event.loaded / event.total) * 100)
      this.statusTarget.textContent = `Subiendo: ${percent}%`
    })
  }
}

Expandiendo el Alcance

La deduplicación con alcance de usuario es el valor predeterminado más seguro, pero podrías querer una deduplicación más amplia en algunos casos. Aquí hay opciones para expandir el alcance:

Opción A: Alcance de organización/inquilino

Para apps SaaS donde los compañeros de equipo comparten archivos, deduplica dentro de la organización. Esto requiere consultar registros propiedad de cualquier miembro de la organización:

def find_org_blob(checksum)
  ActiveStorage::Blob
    .joins(:attachments)
    .where(checksum: checksum)
    .where(org_owns_attachment_sql)
    .first
end

def org_owns_attachment_sql
  # Encuentra blobs adjuntos a registros propiedad de cualquier miembro de la org
  <<~SQL.squish
    (
      (active_storage_attachments.record_type = 'Document'
       AND EXISTS (
         SELECT 1 FROM documents
         WHERE documents.id = active_storage_attachments.record_id
         AND documents.organization_id = #{current_organization.id}
       ))
      OR
      (active_storage_attachments.record_type = 'Project'
       AND EXISTS (
         SELECT 1 FROM projects
         WHERE projects.id = active_storage_attachments.record_id
         AND projects.organization_id = #{current_organization.id}
       ))
    )
  SQL
end

Opción B: Solo archivos públicos

Permite deduplicación global para archivos explícitamente marcados como públicos:

def find_public_blob(checksum)
  ActiveStorage::Blob
    .where(checksum: checksum)
    .where("metadata->>'public' = ?", "true")
    .first
end

def find_existing_blob(checksum)
  find_user_blob(checksum) || find_public_blob(checksum)
end

Opción C: Basado en tipo de contenido (solo servidor)

Deduplica contenido “seguro” como imágenes globalmente, pero mantén documentos con alcance de usuario. Esto solo funciona para deduplicación del lado del servidor (Paso 1) donde tenemos el content_type de la solicitud de carga—no funcionará con la búsqueda previa a la carga (Paso 3):

# En DeduplicatedUploadsController
def find_existing_blob_for_user(checksum)
  content_type = blob_params[:content_type]

  if content_type&.start_with?("image/")
    # Las imágenes pueden deduplicarse globalmente (bajo riesgo)
    ActiveStorage::Blob.find_by(checksum: checksum)
  else
    # Los documentos mantienen alcance de usuario
    find_user_blob(checksum)
  end
end

Manejando Casos Extremos

Condiciones de carrera: El mismo usuario sube un archivo dos veces en rápida sucesión. Ambos checksums regresan como “no encontrado,” ambos se suben. Terminas con dos blobs. Esto está bien. La consulta con alcance de usuario ya maneja esto naturalmente, y puedes limpiar duplicados por usuario con un trabajo en segundo plano:

# app/jobs/deduplicate_user_blobs_job.rb
class DeduplicateUserBlobsJob < ApplicationJob
  def perform(user)
    duplicates = find_duplicate_checksums_for(user)

    duplicates.each do |checksum|
      deduplicate_blobs_with_checksum(user, checksum)
    end
  end

  private

  def find_duplicate_checksums_for(user)
    ActiveStorage::Blob
      .joins(:attachments)
      .where(user_owns_attachment_sql(user))
      .group(:checksum)
      .having("COUNT(DISTINCT active_storage_blobs.id) > 1")
      .pluck(:checksum)
  end

  def deduplicate_blobs_with_checksum(user, checksum)
    ActiveStorage::Blob.transaction do
      blobs = ActiveStorage::Blob
        .joins(:attachments)
        .where(checksum: checksum)
        .where(user_owns_attachment_sql(user))
        .order(:created_at)
        .lock("FOR UPDATE")
        .distinct

      canonical = blobs.first
      return if canonical.nil?

      # Usa where.not en lugar de offset (offset no funciona con find_each)
      blobs.where.not(id: canonical.id).find_each do |duplicate|
        duplicate.attachments.update_all(blob_id: canonical.id)
        duplicate.purge
      end
    end
  end

  def user_owns_attachment_sql(user)
    <<~SQL.squish
      (
        (active_storage_attachments.record_type = 'Document'
         AND EXISTS (SELECT 1 FROM documents WHERE documents.id = active_storage_attachments.record_id AND documents.user_id = #{user.id}))
        OR
        (active_storage_attachments.record_type = 'Avatar'
         AND EXISTS (SELECT 1 FROM avatars WHERE avatars.id = active_storage_attachments.record_id AND avatars.user_id = #{user.id}))
      )
    SQL
  end
end

Blobs huérfanos: El método find_user_blob ya une a través de attachments, así que los blobs huérfanos (aquellos sin adjuntos) se excluyen automáticamente.

Nombres de archivo diferentes: Mismo contenido, nombres diferentes. El blob almacena el nombre de archivo original, pero los adjuntos pueden sobrescribirlo. Esto está bien—deduplica por contenido, no por metadatos.

Previniendo la Eliminación Prematura de Blobs

Hay un problema crítico con blobs compartidos: por defecto, ActiveStorage purga el blob cuando eliminas un registro. Si el Documento A y el Documento B comparten un blob, eliminar el Documento A eliminaría el blob—rompiendo el Documento B.

Arregla esto deshabilitando la purga automática en tus modelos:

# app/models/document.rb
class Document < ApplicationRecord
  belongs_to :user
  has_one_attached :file, dependent: false  # No auto-purgar
end

# app/models/avatar.rb
class Avatar < ApplicationRecord
  belongs_to :user
  has_one_attached :image, dependent: false
end

Ahora los blobs persisten incluso cuando sus adjuntos se eliminan. Limpia blobs huérfanos (aquellos con cero adjuntos) con un trabajo programado:

# app/jobs/cleanup_orphaned_blobs_job.rb
class CleanupOrphanedBlobsJob < ApplicationJob
  def perform
    # Encuentra blobs sin adjuntos, más antiguos de 1 día (período de gracia)
    ActiveStorage::Blob
      .left_joins(:attachments)
      .where(active_storage_attachments: { id: nil })
      .where(active_storage_blobs: { created_at: ...1.day.ago })
      .find_each(&:purge)
  end
end

Prográmalo para ejecutarse diariamente. Con Solid Queue (predeterminado de Rails 8):

# config/recurring.yml
cleanup_orphaned_blobs:
  class: CleanupOrphanedBlobsJob
  schedule: every day at 3am

O con sidekiq-cron:

# config/initializers/sidekiq.rb
Sidekiq::Cron::Job.create(
  name: "Limpiar blobs huérfanos - diario",
  cron: "0 3 * * *",
  class: "CleanupOrphanedBlobsJob"
)

El período de gracia de 1 día previene condiciones de carrera donde un blob se crea pero aún no se adjunta.

Midiendo el Impacto

Rastrea tu tasa de deduplicación:

# En tu controlador
def create
  existing_blob = find_user_blob(blob_params[:checksum])

  if existing_blob
    Rails.logger.info "[Dedup] Blob #{existing_blob.id} reutilizado para usuario #{current_user.id} (#{existing_blob.byte_size} bytes ahorrados)"
    StatsD.increment("uploads.deduplicated")
    StatsD.count("uploads.bytes_saved", existing_blob.byte_size)
    # ...
  end
end

Verifica el potencial de duplicación por usuario:

def duplication_stats_for(user)
  # Encuentra checksums duplicados para los blobs de este usuario
  duplicate_checksums = ActiveStorage::Blob
    .joins(:attachments)
    .where(user_owns_attachment_sql(user))
    .group(:checksum)
    .having("COUNT(DISTINCT active_storage_blobs.id) > 1")
    .pluck(:checksum)

  return { duplicates: 0, wasted_bytes: 0 } if duplicate_checksums.empty?

  # Calcula espacio desperdiciado
  duplicate_blobs = ActiveStorage::Blob
    .joins(:attachments)
    .where(checksum: duplicate_checksums)
    .where(user_owns_attachment_sql(user))
    .distinct

  total_bytes = duplicate_blobs.sum(:byte_size)
  unique_bytes = duplicate_blobs.select("DISTINCT ON (checksum) *").sum(&:byte_size)

  {
    duplicates: duplicate_blobs.count - duplicate_checksums.size,
    wasted_bytes: total_bytes - unique_bytes
  }
end

def user_owns_attachment_sql(user)
  <<~SQL.squish
    (
      (active_storage_attachments.record_type = 'Document'
       AND EXISTS (SELECT 1 FROM documents WHERE documents.id = active_storage_attachments.record_id AND documents.user_id = #{user.id}))
      OR
      (active_storage_attachments.record_type = 'Avatar'
       AND EXISTS (SELECT 1 FROM avatars WHERE avatars.id = active_storage_attachments.record_id AND avatars.user_id = #{user.id}))
    )
  SQL
end

Conclusión

La deduplicación ahorra costos de almacenamiento y hace que las cargas se sientan instantáneas para archivos recurrentes. La idea clave: ActiveStorage ya calcula checksums—solo necesitamos usarlos.

Al limitar el alcance al usuario actual, obtienes los ahorros de almacenamiento sin riesgos de seguridad. Los usuarios solo pueden deduplicar contra sus propias cargas, evitando que reclamen acceso a archivos que no deberían tener.

Comienza con la deduplicación del lado del servidor en el controlador. Añade búsqueda del lado del cliente si quieres omitir las cargas por completo. Expande el alcance a organización o solo archivos públicos cuando tengas un caso de uso claro.

El código en este tutorial funciona con los checksums MD5 predeterminados. Si estás en un entorno FIPS, Rails 8.2 ahora soporta SHA256—la lógica de deduplicación permanece exactamente igual.