Construire un Système de Déduplication de Fichiers avec ActiveStorage
Chaque fois qu’un utilisateur téléverse son logo d’entreprise, sa photo de profil, ou ce même PDF qu’il a déjà téléversé trois fois, vous payez pour le stocker à nouveau. ActiveStorage ne déduplique pas par défaut—chaque téléversement crée un nouveau blob, même si le contenu est identique.
Corrigeons cela. Nous allons construire un système de déduplication qui détecte les fichiers identiques et réutilise les blobs existants, économisant les coûts de stockage et rendant les téléversements instantanés pour les doublons. Nous limiterons la déduplication à chaque utilisateur—ainsi les utilisateurs ne dédupliquent que contre leurs propres téléversements, gardant les fichiers sécurisés.
Comment Fonctionnent les Checksums ActiveStorage
Chaque blob ActiveStorage a une colonne checksum, qui est un hash MD5 du contenu du fichier. Deux fichiers avec un contenu identique auront toujours le même checksum :
ActiveStorage::Blob.pluck(:checksum).tally
# => {"vckNNU4TN7zbzl+o3tjXPQ==" => 47, "x8K9f2mVhLpWQ..." => 12, ...}
Si vous voyez des comptes supérieurs à 1, vous avez des doublons. Éliminons-les.
Ajout d’un Index pour la Performance
ActiveStorage n’indexe pas la colonne checksum par défaut. Sans index, les requêtes de déduplication feront des scans complets de table—acceptable pour les petites apps, mais lent à grande échelle.
Note : Cette migration modifie le schéma d’ActiveStorage. Bien qu’ajouter un index soit à faible risque (pas de changements structurels), sachez que vous personnalisez une table appartenant à un engine. Documentez cette décision pour votre équipe.
class AddIndexToActiveStorageBlobsChecksum < ActiveRecord::Migration[8.0]
disable_ddl_transaction!
def change
add_index :active_storage_blobs, :checksum, algorithm: :concurrently
end
end
L’option algorithm: :concurrently (PostgreSQL) construit l’index sans bloquer les écritures, ce qui est essentiel pour les bases de données en production avec des données existantes. Elle nécessite disable_ddl_transaction! car la création d’index concurrents ne peut pas s’exécuter dans une transaction.
Étape 1 : Déduplication Côté Serveur
Lors de la création d’un blob, vérifiez si l’utilisateur actuel en a déjà un avec ce checksum.
Créez un contrôleur pour gérer les téléversements directs dédupliqués :
# 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?
# Ne trouve que les blobs que l'utilisateur actuel a téléversés auparavant
# Utilise une seule requête efficace avec des sous-requêtes EXISTS
ActiveStorage::Blob
.joins(:attachments)
.where(checksum: checksum)
.where(user_owns_attachment_sql)
.first
end
# Fragment SQL qui vérifie si current_user possède l'enregistrement attaché
# Utilise des sous-requêtes EXISTS pour l'efficacité (sans charger les IDs en mémoire)
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 # Signal pour sauter le téléversement
}
end
def blob_params
params.require(:blob).permit(:filename, :byte_size, :checksum, :content_type, metadata: {})
end
end
Ceci interroge la table active_storage_attachments pour trouver les blobs attachés aux enregistrements que l’utilisateur actuel possède. Ajustez attachable_types et user_record_ids pour correspondre au modèle de propriété de votre application.
Ajoutez la route :
# config/routes.rb
Rails.application.routes.draw do
post '/rails/active_storage/direct_uploads',
to: 'deduplicated_uploads#create',
as: :deduplicated_direct_uploads
end
Comment Fonctionne l’Attachement
Quand nous trouvons un blob existant, nous retournons son signed_id. Le client soumet ce signed_id avec le formulaire, et ActiveStorage crée un nouvel attachement pointant vers le blob existant :
# 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
Quand params[:document][:file] est une chaîne signed_id (pas un fichier téléversé), ActiveStorage trouve le blob et l’attache. Un blob peut avoir plusieurs attachements :
blob = ActiveStorage::Blob.find_by(checksum: "vckNNU4TN7zbzl+o3tjXPQ==")
blob.attachments.count
# => 5 (cinq enregistrements différents partagent ce blob)
C’est la clé de la déduplication : plusieurs enregistrements référencent le même fichier stocké.
Étape 2 : Gestion du Téléversement Côté Client
Le client doit savoir quand sauter le téléversement. Quand direct_upload est null dans la réponse, le fichier existe déjà :
// 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) {
// Créer un input caché avec 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("Le fichier existe déjà - téléversement instantané !")
} else {
this.showMessage("Téléversement terminé")
}
}
// Méthodes déléguées DirectUpload
directUploadWillStoreFileWithXHR(request) {
request.upload.addEventListener("progress", (event) => {
const progress = (event.loaded / event.total) * 100
this.progressTarget.style.width = `${progress}%`
})
}
showMessage(text) {
// Mettre à jour l'UI pour afficher le statut
this.progressTarget.textContent = text
}
}
Utilisez-le dans votre formulaire :
<%= 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 %>
Étape 3 : Sauter Complètement la Requête Réseau
Nous pouvons aller plus loin. Calculez le checksum côté client et vérifiez si le blob existe avant de tenter le téléversement :
Extrayez la logique de portée dans un concern à partager entre les contrôleurs :
# 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
Puis utilisez-le dans le contrôleur de recherche :
# 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
# Utiliser un paramètre de requête pour le checksum (base64 contient +, /, = qui nécessitent un encodage dans les chemins)
get '/blobs/lookup', to: 'blob_lookups#show', as: :blob_lookup
Mettez à jour le contrôleur pour lire depuis les paramètres de requête :
# app/controllers/blob_lookups_controller.rb
def show
blob = find_user_blob(params[:checksum])
# ... le reste inchangé
end
Maintenant le JavaScript peut vérifier d’abord :
// 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 = "Calcul du checksum..."
const checksum = await this.computeChecksum(file)
this.statusTarget.textContent = "Recherche de doublons..."
const existing = await this.lookupBlob(checksum)
if (existing.exists) {
this.statusTarget.textContent = "Fichier déjà téléversé - instantané !"
this.attachSignedId(existing.signed_id)
return
}
this.statusTarget.textContent = "Téléversement..."
await this.performUpload(file)
} catch (error) {
this.statusTarget.textContent = `Erreur : ${error.message}`
console.error("Téléversement échoué :", error)
}
}
computeChecksum(file) {
return new Promise((resolve, reject) => {
FileChecksum.create(file, (error, checksum) => {
if (error) {
reject(new Error(`Checksum échoué : ${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(`Recherche échouée : ${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(`Téléversement échoué : ${error}`))
} else {
this.statusTarget.textContent = "Téléversement terminé"
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 = `Téléversement : ${percent}%`
})
}
}
Élargir la Portée
La déduplication limitée à l’utilisateur est la valeur par défaut la plus sûre, mais vous pourriez vouloir une déduplication plus large dans certains cas. Voici des options pour élargir la portée :
Option A : Portée organisation/locataire
Pour les apps SaaS où les coéquipiers partagent des fichiers, dédupliquez au sein de l’organisation. Cela nécessite d’interroger les enregistrements possédés par n’importe quel membre de l’organisation :
def find_org_blob(checksum)
ActiveStorage::Blob
.joins(:attachments)
.where(checksum: checksum)
.where(org_owns_attachment_sql)
.first
end
def org_owns_attachment_sql
# Trouve les blobs attachés aux enregistrements possédés par n'importe quel membre de l'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
Option B : Fichiers publics uniquement
Permettre la déduplication globale pour les fichiers explicitement marqués comme publics :
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
Option C : Basé sur le type de contenu (côté serveur uniquement)
Dédupliquez le contenu “sûr” comme les images globalement, mais gardez les documents limités à l’utilisateur. Ceci ne fonctionne que pour la déduplication côté serveur (Étape 1) où nous avons le content_type de la requête de téléversement—cela ne fonctionnera pas avec la recherche pré-téléversement (Étape 3) :
# Dans DeduplicatedUploadsController
def find_existing_blob_for_user(checksum)
content_type = blob_params[:content_type]
if content_type&.start_with?("image/")
# Les images peuvent être dédupliquées globalement (faible risque)
ActiveStorage::Blob.find_by(checksum: checksum)
else
# Les documents restent limités à l'utilisateur
find_user_blob(checksum)
end
end
Gestion des Cas Limites
Conditions de concurrence : Le même utilisateur téléverse un fichier deux fois en succession rapide. Les deux checksums reviennent comme “non trouvé,” les deux se téléversent. Vous vous retrouvez avec deux blobs. C’est normal. La requête limitée à l’utilisateur gère déjà cela naturellement, et vous pouvez nettoyer les doublons par utilisateur avec un job en arrière-plan :
# 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?
# Utiliser where.not au lieu de offset (offset ne fonctionne pas avec 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 orphelins : La méthode find_user_blob fait déjà une jointure via attachments, donc les blobs orphelins (ceux sans attachements) sont automatiquement exclus.
Noms de fichiers différents : Même contenu, noms différents. Le blob stocke le nom de fichier original, mais les attachements peuvent le remplacer. C’est normal—dédupliquez sur le contenu, pas sur les métadonnées.
Prévenir la Suppression Prématurée des Blobs
Il y a un problème critique avec les blobs partagés : par défaut, ActiveStorage purge le blob quand vous supprimez un enregistrement. Si le Document A et le Document B partagent un blob, supprimer le Document A supprimerait le blob—cassant le Document B.
Corrigez cela en désactivant la purge automatique sur vos modèles :
# app/models/document.rb
class Document < ApplicationRecord
belongs_to :user
has_one_attached :file, dependent: false # Pas d'auto-purge
end
# app/models/avatar.rb
class Avatar < ApplicationRecord
belongs_to :user
has_one_attached :image, dependent: false
end
Maintenant les blobs persistent même quand leurs attachements sont supprimés. Nettoyez les blobs orphelins (ceux avec zéro attachement) avec un job planifié :
# app/jobs/cleanup_orphaned_blobs_job.rb
class CleanupOrphanedBlobsJob < ApplicationJob
def perform
# Trouve les blobs sans attachements, plus vieux d'1 jour (période de grâce)
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
Planifiez-le pour s’exécuter quotidiennement. Avec Solid Queue (défaut Rails 8) :
# config/recurring.yml
cleanup_orphaned_blobs:
class: CleanupOrphanedBlobsJob
schedule: every day at 3am
Ou avec sidekiq-cron :
# config/initializers/sidekiq.rb
Sidekiq::Cron::Job.create(
name: "Nettoyer les blobs orphelins - quotidien",
cron: "0 3 * * *",
class: "CleanupOrphanedBlobsJob"
)
La période de grâce d’1 jour prévient les conditions de concurrence où un blob est créé mais pas encore attaché.
Mesurer l’Impact
Suivez votre taux de déduplication :
# Dans votre contrôleur
def create
existing_blob = find_user_blob(blob_params[:checksum])
if existing_blob
Rails.logger.info "[Dedup] Blob #{existing_blob.id} réutilisé pour utilisateur #{current_user.id} (#{existing_blob.byte_size} octets économisés)"
StatsD.increment("uploads.deduplicated")
StatsD.count("uploads.bytes_saved", existing_blob.byte_size)
# ...
end
end
Vérifiez le potentiel de duplication par utilisateur :
def duplication_stats_for(user)
# Trouve les checksums dupliqués pour les blobs de cet utilisateur
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?
# Calculer l'espace gaspillé
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
Conclusion
La déduplication économise les coûts de stockage et rend les téléversements instantanés pour les fichiers récurrents. L’idée clé : ActiveStorage calcule déjà les checksums—nous devons juste les utiliser.
En limitant la portée à l’utilisateur actuel, vous obtenez les économies de stockage sans risques de sécurité. Les utilisateurs ne peuvent dédupliquer que contre leurs propres téléversements, les empêchant de réclamer l’accès à des fichiers qu’ils ne devraient pas avoir.
Commencez par la déduplication côté serveur dans le contrôleur. Ajoutez la recherche côté client si vous voulez sauter complètement les téléversements. Élargissez la portée à l’organisation ou aux fichiers publics uniquement quand vous avez un cas d’utilisation clair.
Le code dans ce tutoriel fonctionne avec les checksums MD5 par défaut. Si vous êtes dans un environnement FIPS, Rails 8.2 supporte maintenant SHA256—la logique de déduplication reste exactement la même.