The Solid Triumvirate in Action

Welcome to this fourth article in the series on the Solid Triumvirate in Rails 8! And we finish in style by building together a mini-application: a real-time notification system.


We will also discuss real-world use cases and recommendations

  1. How to optimize in production
  2. How to deploy in production
  3. How to address the most common issues


Feel free to browse the previous articles to refresh your knowledge on the subject before tackling this mini-application:


  1. "Solid Cache" : No longer depending on Redis for cache in Rails
  2. "Solid Cable" : Websockets and real-time without hassle
  3. "Solid Queue" : Background tasks in Rails style
  4. The triumvirate applied to a complete project


Hang on, it's going to be solid :)

The Solid Triumvirate in a Complete Rails 8 Project

Let's build a mini-application that uses all three Solid technologies together: a real-time notifications system with cache.

The Application: Real-Time Notifications

Features:

  1. Create notifications for users
  2. Display them in real time via WebSocket
  3. Cache the number of unread notifications
  4. Process notifications in the background to send emails

Project Structure

# vérifiez que vous avez Rails 8
rails -v
> 8.0.0.1
# Créez un nouveau projet
rails new solid_notifications
cd solid_notifications
rails db:create


Configure cable.yml to use Solid Cable instead of async in development.

# Async adapter only works within the same process, so for manually triggering cable updates from a console,
# and seeing results in the browser, you must do so from the web console (running inside the dev process),
# not a terminal started via bin/rails console! Add "console" to any action or any ERB template view
# to make the web console appear.
development:
adapter: solid_cable
test:
adapter: test
production:
adapter: solid_cable
connects_to:
database:
writing: cable
polling_interval: 0.1.seconds
message_retention: 1.day


In development you need to add the Solid Cable database schema to the database. In production, we will have a dedicated database, so it's not needed.

bin/rails db:schema:load SCHEMA=db/cable_schema.rb

Models

rails g model User name:string email:string
rails g model Notification user:references title:string content:text read:boolean
rails db:migrate


# app/models/user.rb
class User < ApplicationRecord
has_many :notifications, dependent: :destroy
def unread_notifications_count
# Utiliser Solid Cache pour éviter de compter à chaque fois
Rails.cache.fetch("user_#{id}_unread_count", expires_in: 5.minutes) do
notifications.where(read: [false, nil]).count
end
end
def mark_notification_as_read(notification_id)
notification = notifications.find(notification_id)
notification.update(read: true)
# Invalider le cache
Rails.cache.delete("user_#{id}_unread_count")
end
end


# app/models/notification.rb
class Notification < ApplicationRecord
belongs_to :user
after_create_commit do
# 1. Broadcaster via Solid Cable
broadcast_notification
# 2. Enqueuer un job via Solid Queue pour envoyer l'email
NotificationEmailJob.perform_later(self.id)
# 3. Invalider le cache Solid Cache
Rails.cache.delete("user_#{user_id}_unread_count")
end
# Diffuser les changements d'état (ex: lu/non lu)
after_update_commit :broadcast_read_update, if: :saved_change_to_read?
private
def broadcast_notification
broadcast_prepend_to(
"user_#{user_id}_notifications",
target: "notifications-list",
partial: "notifications/notification",
locals: { notification: self }
)
# Broadcaster aussi le nouveau compte
broadcast_replace_to(
"user_#{user_id}_notifications",
target: "notifications-count",
partial: "notifications/count",
locals: { count: user.notifications.where(read: [false, nil]).count }
)
end
def broadcast_read_update
# Remplacer l'élément de notification pour refléter l'état "lu"
broadcast_replace_to(
"user_#{user_id}_notifications",
target: ActionView::RecordIdentifier.dom_id(self),
partial: "notifications/notification",
locals: { notification: self }
)
# Mettre à jour le compteur
broadcast_replace_to(
"user_#{user_id}_notifications",
target: "notifications-count",
partial: "notifications/count",
locals: { count: user.notifications.where(read: [false, nil]).count }
)
end
end

Background Job (Solid Queue)

# app/jobs/notification_email_job.rb
class NotificationEmailJob < ApplicationJob
queue_as :mailers
retry_on StandardError, wait: :exponentially_longer, attempts: 5
def perform(notification_id)
notification = Notification.find(notification_id)
# Simuler l'envoi d'email (remplacer par votre mailer)
Rails.logger.info "Sending email for notification #{notification_id} to #{notification.user.email}"
# UserMailer.notification_email(notification).deliver_now
# Simuler un délai d'envoi
sleep(2)
Rails.logger.info "Email sent successfully for notification #{notification_id}"
end
end

WebSocket Channel (Solid Cable)

# app/channels/notifications_channel.rb
class NotificationsChannel < ApplicationCable::Channel
def subscribed
# S'abonner au stream personnel de l'utilisateur
stream_from "user_#{current_user.id}_notifications"
end
def unsubscribed
# Cleanup
end
end

Controllers

# app/controllers/notifications_controller.rb
class NotificationsController < ApplicationController
before_action :set_current_user # Simuler l'authentification
def index
# Utiliser Solid Cache pour cacher la liste
@notifications = Rails.cache.fetch("user_#{current_user.id}_notifications", expires_in: 1.minute) do
current_user.notifications.order(created_at: :desc).limit(50).to_a
end
@unread_count = current_user.unread_notifications_count
end
def create
@notification = current_user.notifications.build(notification_params)
if @notification.save
# Le broadcast et le job sont gérés automatiquement par le callback
head :ok
else
render json: { errors: @notification.errors }, status: :unprocessable_entity
end
end
def mark_as_read
current_user.mark_notification_as_read(params[:id])
head :ok
end
private
def notification_params
params.require(:notification).permit(:title, :content)
end
def set_current_user
# Simuler un utilisateur connecté (remplacer par votre système d'auth)
@current_user = User.first || User.create(name: "Test User", email: "test@example.com")
end
def current_user
@current_user
end
helper_method :current_user
end

Views

<!-- app/views/notifications/index.html.erb -->
<div class="notifications-container">
<%= turbo_stream_from "user_#{current_user.id}_notifications" %>
<div class="header">
<h1>Mes Notifications</h1>
<%= render "notifications/count", count: @unread_count %>
</div>
<!-- Formulaire de test pour créer une notification -->
<div class="create-notification">
<%= form_with(
model: Notification.new,
url: notifications_path
) do |form| %>
<%= form.text_field :title, placeholder: "Titre" %>
<%= form.text_area :content, placeholder: "Contenu" %>
<%= form.submit "Créer une notification (test)" %>
<% end %>
</div>
<!-- Liste des notifications -->
<div id="notifications-list">
<%= render @notifications %>
</div>
</div>


<!-- app/views/notifications/_notification.html.erb -->
<div class="notification <%= 'unread' unless notification.read %>" id="<%= dom_id(notification) %>">
<div class="notification-content">
<h3><%= notification.title %></h3>
<p><%= notification.content %></p>
class="time"><%= time_ago_in_words(notification.created_at) %> ago</span>
</div>
<% unless notification.read %>
<%= button_to "Marquer comme lu",
mark_as_read_notification_path(notification),
method: :patch,
form: { data: { turbo_stream: true } } %>
<% end %>
</div>


<!-- app/views/notifications/_count.html.erb -->
<span id="notifications-count" class="badge <%= 'has-unread' if count > 0 %>">
<%= count %> non lues
</span>

Routes

# config/routes.rb
Rails.application.routes.draw do
resources :notifications, only: [:index, :create] do
member do
patch :mark_as_read
end
end
root "notifications#index"
end

CSS (optional, for styling)

/* app/assets/stylesheets/notifications.css */
.notifications-container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
}
.badge {
padding: 5px 15px;
background: #e0e0e0;
border-radius: 20px;
font-size: 14px;
}
.badge.has-unread {
background: #ff5252;
color: white;
}
.notification {
background: white;
border: 1px solid #ddd;
border-radius: 8px;
padding: 20px;
margin-bottom: 15px;
display: flex;
justify-content: space-between;
align-items: center;
}
.notification.unread {
border-left: 4px solid #2196F3;
background: #f0f8ff;
}
.notification h3 {
margin: 0 0 10px 0;
font-size: 18px;
}
.notification p {
margin: 0 0 5px 0;
color: #666;
}
.time {
font-size: 12px;
color: #999;
}
button {
background: #2196F3;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background: #1976D2;
}
.create-notification {
background: #f5f5f5;
padding: 20px;
border-radius: 8px;
margin-bottom: 30px;
}
.create-notification input,
.create-notification textarea {
width: 100%;
padding: 10px;
margin-bottom: 10px;
border: 1px solid #ddd;
border-radius: 4px;
}

Test the Application

  1. Start the server :
bin/dev
  1. Open two browser windows on http://localhost:3000
  2. In the first window, create a notification using the form
  3. Watch the magic :✅ The notification appears instantly in both windows (Solid Cable)
  4. ✅ The counter updates in real time (Solid Cable)
  5. ✅ In the logs, you will see the email job running (Solid Queue)
  6. ✅ If you refresh, the list and the counter are fast (Solid Cache)
  7. Mark a notification as read :The badge updates
  8. The cache is automatically invalidated


What happens behind the scenes

When you create a notification:

  1. The model is saved in PostgreSQL
  2. The callback after_create_commit triggers : Solid Cable : Broadcast of the notification via WebSocket (table solid_cable_messages)
  3. Solid Queue : Enqueue the email job (table solid_queue_jobs)
  4. Solid Cache : Invalidation of the cache (deletion in solid_cache_entries)
  5. The connected clients receive the WebSocket message and Turbo Stream updates the DOM
  6. The Solid Queue worker picks up the job and sends the email in the background
  7. Subsequent visits use the cache to display the list quickly

Real-World Use Cases and Recommendations

When to Use the Solid Triumvirate?

The Solid Triumvirate is perfect for:

✅ Startups and Side Projects

  1. Tight budget (no Redis needed)
  2. Simple infrastructure
  3. Quick deployment
  4. Fewer services to maintain

✅ Small to Medium Applications

  1. < 10,000 active users
  2. < 1,000 jobs per hour
  3. < 500 concurrent WebSocket connections
  4. Moderate and predictable traffic

✅ Projects where Simplicity Matters

  1. Small development team
  2. No dedicated DevOps
  3. Internal enterprise applications
  4. MVPs and prototypes

✅ Hosting on Simple Platforms

  1. Heroku, Render, Fly.io
  2. No complex network configuration
  3. A single server or simple horizontal scaling

When to Move to Redis/Sidekiq?

Consider migrating to Redis solutions if:

❌ Critical Performance

  1. WebSocket latency < 50ms required
10,000 jobs par heure
1,000 connexions WebSocket simultanées
  1. Cache with millions of entries

❌ Advanced Features Needed

  1. Complex Pub/Sub
  2. Sorted sets, HyperLogLog
  3. Lua scripting
  4. Redis-specific data structures

❌ Massive Scaling

  1. Distributed app across multiple data centers
  2. Microservices requiring a message broker
  3. Need for advanced sharding

Hybrid Architecture: The Best of Both Worlds

You can also mix approaches:


# config/environments/production.rb
# Solid Cache pour la plupart des choses
config.cache_store = :solid_cache_store
# Mais Redis pour des données ultra-critiques
REDIS_CACHE = ActiveSupport::Cache::RedisCacheStore.new(
url: ENV['REDIS_URL']
)
# Dans votre code
# Cache normal
Rails.cache.fetch('user_profile') { ... }
# Cache critique avec Redis
REDIS_CACHE.fetch('critical_data') { ... }

Optimizations and Best Practices

Solid Cache

1. Pick the Right Expiration Durations

# ❌ Mauvais : trop court, cache peu utile
Rails.cache.fetch('products', expires_in: 10.seconds) { ... }
# ✅ Bon : équilibré selon la fréquence de mise à jour
Rails.cache.fetch('products', expires_in: 15.minutes) { ... }
# ✅ Bon : données rarement modifiées
Rails.cache.fetch('static_config', expires_in: 1.day) { ... }


2. Use Descriptive Keys

# ❌ Mauvais : clé vague
Rails.cache.fetch('data') { ... }
# ✅ Bon : clé descriptive et versionnée
Rails.cache.fetch("user_#{user.id}_dashboard_v2") { ... }


3. Smart Invalidation

# app/models/product.rb
class Product < ApplicationRecord
after_save :clear_related_caches
private
def clear_related_caches
Rails.cache.delete('all_products')
Rails.cache.delete("product_#{id}")
Rails.cache.delete("category_#{category_id}_products")
end
end


4. View Cache Fragments

<!-- ❌ Mauvais : cache trop large -->
<% cache 'entire_page' do %>
<%= render @products %>
<%= render 'user_panel' %>
<% end %>
<!-- ✅ Bon : cache granulaire -->
<% cache 'product_list' do %>
<%= render @products %>
<% end %>
<% cache ['user_panel', current_user.id, current_user.updated_at] do %>
<%= render 'user_panel' %>
<% end %>


5. Monitor Cache Size

# lib/tasks/cache_maintenance.rake
namespace :cache do
desc "Report cache statistics"
task stats: :environment do
total_entries = SolidCache::Entry.count
total_size = SolidCache::Entry.sum(:byte_size) / 1.megabyte
puts "Total entries: #{total_entries}"
puts "Total size: #{total_size.round(2)} MB"
# Nettoyer si trop grand
if total_size > 1024 # > 1GB
puts "Cache too large, cleaning old entries..."
SolidCache::Entry.where('created_at < ?', 7.days.ago).delete_all
end
end
end

Solid Cable

1. Limit Broadcasts

# ❌ Mauvais : broadcast à chaque changement
class Product < ApplicationRecord
after_update :broadcast_update
end
# ✅ Bon : broadcaster uniquement si nécessaire
class Product < ApplicationRecord
after_update :broadcast_update, if: :should_broadcast?
private
def should_broadcast?
saved_change_to_price? || saved_change_to_stock?
end
end


2. Batch Messages

# ❌ Mauvais : 100 broadcasts pour 100 produits
products.each do |product|
product.broadcast_update
end
# ✅ Bon : 1 broadcast global
broadcast_replace_to(
'products_list',
target: 'products',
partial: 'products/list',
locals: { products: products }
)


3. Clean Disconnections

// ❌ Mauvais : ne jamais se désabonner
let subscription = consumer.subscriptions.create("ChatChannel", {})
// ✅ Bon : se désabonner quand on quitte la page
export default class extends Controller {
connect() {
this.subscription = consumer.subscriptions.create("ChatChannel", {})
}
disconnect() {
if (this.subscription) {
this.subscription.unsubscribe()
}
}
}


4. Set an Appropriate Polling Interval

# config/cable.yml
# ❌ Trop fréquent : surcharge inutile
production:
adapter: solid_cable
polling_interval: 0.01.seconds # Toutes les 10ms !
# ✅ Équilibré pour la plupart des cas
production:
adapter: solid_cable
polling_interval: 0.1.seconds # Toutes les 100ms
# ✅ Pour du temps réel moins critique
production:
adapter: solid_cable
polling_interval: 0.5.seconds # Toutes les 500ms

Solid Queue

1. Job Idempotence

Your jobs should be idempotent: if they run multiple times, the result should be the same.

# ❌ Mauvais : pas idempotent
class IncrementCounterJob < ApplicationJob
def perform(user_id)
user = User.find(user_id)
user.increment!(:counter) # Si exécuté 2 fois, counter += 2 !
end
end
# ✅ Bon : idempotent
class UpdateCounterJob < ApplicationJob
def perform(user_id)
user = User.find(user_id)
new_count = user.posts.count # Recalcule à chaque fois
user.update(counter: new_count)
end
end


2. Appropriate Timeouts

class LongRunningJob < ApplicationJob
# ❌ Mauvais : pas de timeout, peut bloquer
def perform
loop do
# Code qui pourrait tourner indéfiniment
end
end
end
# ✅ Bon : avec timeout
class LongRunningJob < ApplicationJob
def perform
Timeout.timeout(5.minutes) do
# Code avec limite de temps
end
rescue Timeout::Error
Rails.logger.error "Job timeout after 5 minutes"
raise
end
end


3. Type-Specific Queues

# config/solid_queue.yml
production:
workers:
# Jobs critiques : haute priorité
- queues: critical
threads: 5
processes: 2
polling_interval: 0.1
# Emails : beaucoup de threads (I/O)
- queues: mailers
threads: 10
processes: 2
polling_interval: 1
# Rapports : peu de threads (CPU)
- queues: reports
threads: 2
processes: 1
polling_interval: 5


# Utiliser les bonnes queues
class UrgentJob < ApplicationJob
queue_as :critical
end
class NewsletterJob < ApplicationJob
queue_as :mailers
end
class MonthlyReportJob < ApplicationJob
queue_as :reports
end


4. Detailed Logging

class ImportJob < ApplicationJob
def perform(file_id)
file = ImportFile.find(file_id)
Rails.logger.info "Starting import of file #{file_id}"
start_time = Time.current
begin
result = process_file(file)
duration = Time.current - start_time
Rails.logger.info "Completed import of file #{file_id} in #{duration}s. #{result[:count]} records imported"
rescue => e
Rails.logger.error "Failed import of file #{file_id}: #{e.class} - #{e.message}"
Rails.logger.error e.backtrace.join("\n")
raise
end
end
end


5. Use Priorities

# Priorité par défaut : jobs FIFO
RegularJob.perform_later
# Haute priorité (nombre plus bas = plus prioritaire)
UrgentJob.set(priority: 0).perform_later
# Basse priorité
CleanupJob.set(priority: 100).perform_later

Production Deployment

Database Configuration

Option 1: Separate Databases (Recommended)

# config/database.yml
production:
primary:
<<: *default
database: mon_app_production
username: mon_app
password: <%= ENV["DATABASE_PASSWORD"] %>
cache:
<<: *default
database: mon_app_cache_production
username: mon_app
password: <%= ENV["DATABASE_PASSWORD"] %>
migrations_paths: db/cache_migrate
queue:
<<: *default
database: mon_app_queue_production
username: mon_app
password: <%= ENV["DATABASE_PASSWORD"] %>
migrations_paths: db/queue_migrate
cable:
<<: *default
database: mon_app_cable_production
username: mon_app
password: <%= ENV["DATABASE_PASSWORD"] %>


Advantages:

  1. Total isolation
  2. No impact from jobs on user requests
  3. Independent scaling

Disadvantages:

  1. More databases to manage


Option 2: Single Database (Simpler)

# config/database.yml
production:
primary:
<<: *default
database: mon_app_production
cache:
<<: *default
database: mon_app_production
migrations_paths: db/cache_migrate
queue:
<<: *default
database: mon_app_production
migrations_paths: db/queue_migrate
cable:
<<: *default
database: mon_app_production


Advantages:

  1. Ultra-simple configuration
  2. One database to manage
  3. Lower costs

Disadvantages:

  1. Potential contention under heavy load

Recommendation: Start with a single database. Migrate to separate databases if you encounter performance problems.

With Kamal (Recommended for Rails 8)

# config/deploy.yml
service: mon_app
image: mon_app/mon_app
servers:
web:
- 192.168.1.100
job:
hosts:
- 192.168.1.101
cmd: bin/jobs
env:
secret:
- RAILS_MASTER_KEY
- DATABASE_URL
- CACHE_DATABASE_URL
- QUEUE_DATABASE_URL
- CABLE_DATABASE_URL


Deploy:

kamal setup
kamal deploy

With Docker + Docker Compose

# docker-compose.yml
version: '3.8'
services:
web:
build: .
command: bundle exec rails server -b 0.0.0.0
ports:
- "3000:3000"
environment:
DATABASE_URL: postgresql://postgres:password@db:5432/mon_app_production
depends_on:
- db
worker:
build: .
command: bundle exec bin/jobs
environment:
DATABASE_URL: postgresql://postgres:password@db:5432/mon_app_production
depends_on:
- db
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: password
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:

Important Environment Variables

# .env.production
RAILS_ENV=production
RAILS_MASTER_KEY=your_master_key_here
# Database
DATABASE_URL=postgresql://user:pass@host:5432/app_production
# Si bases séparées
CACHE_DATABASE_URL=postgresql://user:pass@host:5432/app_cache_production
QUEUE_DATABASE_URL=postgresql://user:pass@host:5432/app_queue_production
CABLE_DATABASE_URL=postgresql://user:pass@host:5432/app_cable_production
# Solid Queue
SOLID_QUEUE_IN_PUMA=true # Si vous voulez utiliser le plugin Puma
# Monitoring (optionnel)
SENTRY_DSN=your_sentry_dsn

Monitoring and Alerts

1. Monitor Queues

# lib/tasks/monitoring.rake
namespace :monitoring do
desc "Check queue health"
task queue_health: :environment do
pending_jobs = SolidQueue::Job.count
failed_jobs = SolidQueue::FailedExecution.count
if pending_jobs > 1000
alert("High pending jobs count: #{pending_jobs}")
end
if failed_jobs > 100
alert("High failed jobs count: #{failed_jobs}")
end
end
def alert(message)
# Envoyer une alerte (Slack, email, etc.)
Rails.logger.error("[ALERT] #{message}")
end
end


With cron:

# Vérifier toutes les 5 minutes
*/5 * * * * cd /var/www/mon_app && bundle exec rake monitoring:queue_health


2. Monitor the Cache

namespace :monitoring do
desc "Check cache health"
task cache_health: :environment do
cache_size = SolidCache::Entry.sum(:byte_size) / 1.gigabyte.to_f
if cache_size > 10 # > 10GB
alert("Cache size is large: #{cache_size.round(2)} GB")
end
# Vérifier les performances
start = Time.current
Rails.cache.read('health_check_key')
duration = Time.current - start
if duration > 0.1 # > 100ms
alert("Cache read is slow: #{(duration * 1000).round(2)}ms")
end
end
end


3. Structured Logs

# config/environments/production.rb
config.log_formatter = proc do |severity, datetime, progname, msg|
{
timestamp: datetime.iso8601,
severity: severity,
program: progname,
message: msg
}.to_json + "\n"
end

Troubleshooting: Common Issues

Solid Cache

Issue: "Cache is very slow"

Diagnosis:

# Mesurer la vitesse du cache
require 'benchmark'
time = Benchmark.realtime do
Rails.cache.read('test_key')
end
puts "Cache read took #{(time * 1000).round(2)}ms"

Solutions:

  1. Verify that indexes are created
  2. Increase the database RAM
  3. Use a faster SSD
  4. Check that max_size is not exceeded

Issue: "Table solid_cache_entries too large"

# Voir la taille
SolidCache::Entry.sum(:byte_size) / 1.gigabyte # En GB
# Solution 1 : Réduire max_size dans config/cache.yml
# Solution 2 : Nettoyer manuellement
SolidCache::Entry.where('created_at < ?', 7.days.ago).delete_all

Solid Cable

Issue: "Messages are not received"

Checks:

  1. Verify that Solid Cable is actually in use:
# config/cable.yml
development:
adapter: solid_cable # Pas "async" !
  1. Check the WebSocket connection in the browser console:
// Dans la console du navigateur
App.cable.connection.isActive() // Doit retourner true
  1. Check Rails logs for broadcast errors

Solution: Often it's a cable.yml configuration issue or a missing migration.

Issue: "High latency"

Diagnosis:

# Réduire le polling_interval
production:
adapter: solid_cable
polling_interval: 0.05.seconds # Plus fréquent (50ms au lieu de 100ms)

Note: Too frequent polling increases database load!

Solid Queue

Issue: "Jobs do not run"

Checks:

  1. Is the worker running?
ps aux | grep jobs
  1. Are there any jobs pending?
SolidQueue::Job.count # Devrait augmenter si jobs ne s'exécutent pas
  1. Check the worker logs:
tail -f log/production.log | grep SolidQueue


Common solutions:

  1. Restart the worker: bin/jobs
  2. Check the solid_queue.yml configuration
  3. Ensure migrations are applied


Issue: "Jobs fail in a loop"

# Voir les jobs échoués
failed = SolidQueue::FailedExecution.all
failed.each do |f|
puts "Job: #{f.job_class}"
puts "Error: #{f.error}"
puts "Executions: #{f.executions}"
puts "---"
end


Solutions:

  1. Fix the bug in the job
  2. Increase the number of retries
  3. Add discard_on for certain errors
  4. Remove irrecoverable jobs:


SolidQueue::FailedExecution.where('executions > 10').delete_all

Issue: "Table solid_queue_jobs too large"

# Voir le nombre de jobs
SolidQueue::Job.count
# Nettoyer les jobs terminés depuis longtemps
SolidQueue::Job.finished_before(7.days.ago).delete_all


Prevention:

# Tâche cron quotidienne
# lib/tasks/cleanup.rake
namespace :cleanup do
desc "Clean old finished jobs"
task old_jobs: :environment do
deleted = SolidQueue::Job.finished_before(7.days.ago).delete_all
puts "Deleted #{deleted} old jobs"
end
end

Migration from Redis/Sidekiq

Do you already have an app using Redis and Sidekiq and want to migrate to the Solid Triumvirate? Here's how.

Cache Migration (Redis → Solid Cache)

Step 1: Install Solid Cache

bundle add solid_cache
bin/rails solid_cache:install
rails db:migrate

Step 2: Migrate gradually

# config/environments/production.rb
# Garder les deux pendant la transition
PRIMARY_CACHE = ActiveSupport::Cache::SolidCacheStore.new
REDIS_CACHE = ActiveSupport::Cache::RedisCacheStore.new(url: ENV['REDIS_URL'])
# Utiliser Solid Cache par défaut
config.cache_store = :solid_cache_store


In your code, you can test in parallel:

# Écrire dans les deux
Rails.cache.write('key', value)
REDIS_CACHE.write('key', value)
# Lire de Solid Cache, fallback sur Redis
value = Rails.cache.read('key') || REDIS_CACHE.read('key')

Step 3: Monitoring

Monitor performance for a few days. If all goes well, remove Redis.

Jobs Migration (Sidekiq → Solid Queue)

Step 1: Installation

bundle add solid_queue
bin/rails solid_queue:install
rails db:migrate

Step 2: Adapt the Jobs

Active Job jobs work out of the box, but check Sidekiq specifics:

# Avant (Sidekiq)
class MyJob
include Sidekiq::Job
sidekiq_options retry: 5, queue: 'critical'
def perform(user_id)
# ...
end
end
# Après (Solid Queue)
class MyJob < ApplicationJob
queue_as :critical
retry_on StandardError, attempts: 5
def perform(user_id)
# ...
end
end

Step 3: Temporary Coexistence

You can run both in parallel:

# config/application.rb
config.active_job.queue_adapter = :sidekiq # Par défaut Sidekiq
# Pour certains jobs spécifiques
class TestJob < ApplicationJob
self.queue_adapter = :solid_queue # Ce job utilise Solid Queue
def perform
# ...
end
end

Step 4: Progressive Migration

  1. Migrate first non-critical jobs
  2. Monitor performance
  3. Migrate critical jobs
  4. Once stable, remove Sidekiq

Action Cable Migration

# config/cable.yml
# Avant
production:
adapter: redis
url: <%= ENV.fetch("REDIS_URL") %>
# Après
production:
adapter: solid_cable
connects_to:
database:
writing: cable
polling_interval: 0.1.seconds

Restart the app and test real-time features.

Conclusion: The Future of Rails


The Solid Triumvirate (Cache, Cable, Queue) represents more than a simple technical change in Rails 8. It is a philosophy: making Rails development simpler, more accessible, and more economical.

What We Learned

In this article, we have explored in depth:

  1. Solid Cache : A database-backed caching system that rivals Redis for most applications, while offering much greater storage capacity
  2. Solid Cable : A WebSocket solution that eliminates the need for Redis for Action Cable, making real-time accessible to all
  3. Solid Queue : A performant and reliable background job manager with no external dependency

Key advantages of the Triumvirate

  1. Simplicity : A single technology (your database) instead of three or four
  2. Lower costs : No need for expensive external services
  3. Easy deployment : Less config, less maintenance
  4. Reasonable performance : Sufficient for 80% of Rails apps
  5. Modern approach : Takes advantage of fast SSDs and advanced databases

When to adopt the Solid Triumvirate?

Yes, adopt it if:

  1. You are starting a new Rails 8 project
  2. You are a startup or small team
  3. You want to reduce complexity
  4. Your application does not require extreme performance

Maybe, depending on context:

  1. You are migrating an existing application
  2. You have high but not extreme performance needs
  3. You want to test first before committing

No, keep your current solutions if:

  1. You have millions of users
  2. Latency must be < 50ms
  3. You process tens of thousands of jobs per minute
  4. You rely on advanced Redis features

The Incremental Approach

You don't have to adopt everything at once! Start with one component:

  1. Solid Cache first : The easiest to adopt
  2. Solid Queue next : When you're comfortable with the cache
  3. Solid Cable last : If you need real-time

Resources to go further

  1. Official Rails documentation : guides.rubyonrails.org
  2. GitHub Solid Cache : github.com/rails/solid_cache
  3. GitHub Solid Cable : github.com/rails/solid_cable
  4. GitHub Solid Queue : github.com/rails/solid_queue
  5. DHH's Blog : Announcements and philosophy behind Rails 8

Final words

The Solid Triumvirate isn't perfect for all use cases, but it represents a major step forward for the Rails community. It makes modern web development accessible to everyone, without sacrificing quality or features.


Whether you're a junior or an experienced developer, I hope this article has given you a clear and practical understanding of Solid Cache, Solid Cable, and Solid Queue. These three technologies are likely to become the standard for Rails applications in the years to come.

So don't hesitate: dive in, experiment, and build modern Rails applications with the Solid Triumvirate!


Have questions? Feel free to consult the official documentation or join the Rails community for help.


Happy coding! 🚀