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.