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
- How to optimize in production
- How to deploy in production
- 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:
- "Solid Cache" : No longer depending on Redis for cache in Rails
- "Solid Cable" : Websockets and real-time without hassle
- "Solid Queue" : Background tasks in Rails style
- 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:
- Create notifications for users
- Display them in real time via WebSocket
- Cache the number of unread notifications
- 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
- Start the server :
- Open two browser windows on http://localhost:3000
- In the first window, create a notification using the form
- Watch the magic :✅ The notification appears instantly in both windows (Solid Cable)
- ✅ The counter updates in real time (Solid Cable)
- ✅ In the logs, you will see the email job running (Solid Queue)
- ✅ If you refresh, the list and the counter are fast (Solid Cache)
- Mark a notification as read :The badge updates
- The cache is automatically invalidated
What happens behind the scenes
When you create a notification:
- The model is saved in PostgreSQL
- The callback after_create_commit triggers : Solid Cable : Broadcast of the notification via WebSocket (table solid_cable_messages)
- Solid Queue : Enqueue the email job (table solid_queue_jobs)
- Solid Cache : Invalidation of the cache (deletion in solid_cache_entries)
- The connected clients receive the WebSocket message and Turbo Stream updates the DOM
- The Solid Queue worker picks up the job and sends the email in the background
- 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
- Tight budget (no Redis needed)
- Simple infrastructure
- Quick deployment
- Fewer services to maintain
✅ Small to Medium Applications
- < 10,000 active users
- < 1,000 jobs per hour
- < 500 concurrent WebSocket connections
- Moderate and predictable traffic
✅ Projects where Simplicity Matters
- Small development team
- No dedicated DevOps
- Internal enterprise applications
- MVPs and prototypes
✅ Hosting on Simple Platforms
- Heroku, Render, Fly.io
- No complex network configuration
- A single server or simple horizontal scaling
When to Move to Redis/Sidekiq?
Consider migrating to Redis solutions if:
❌ Critical Performance
- WebSocket latency < 50ms required
10,000 jobs par heure
1,000 connexions WebSocket simultanées
- Cache with millions of entries
❌ Advanced Features Needed
- Complex Pub/Sub
- Sorted sets, HyperLogLog
- Lua scripting
- Redis-specific data structures
❌ Massive Scaling
- Distributed app across multiple data centers
- Microservices requiring a message broker
- 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:
- Total isolation
- No impact from jobs on user requests
- Independent scaling
Disadvantages:
- 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:
- Ultra-simple configuration
- One database to manage
- Lower costs
Disadvantages:
- 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:
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:
- Verify that indexes are created
- Increase the database RAM
- Use a faster SSD
- 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:
- Verify that Solid Cable is actually in use:
# config/cable.yml
development:
adapter: solid_cable # Pas "async" !
- Check the WebSocket connection in the browser console:
// Dans la console du navigateur
App.cable.connection.isActive() // Doit retourner true
- 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:
- Is the worker running?
- Are there any jobs pending?
SolidQueue::Job.count # Devrait augmenter si jobs ne s'exécutent pas
- Check the worker logs:
tail -f log/production.log | grep SolidQueue
Common solutions:
- Restart the worker: bin/jobs
- Check the solid_queue.yml configuration
- 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:
- Fix the bug in the job
- Increase the number of retries
- Add discard_on for certain errors
- 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
- Migrate first non-critical jobs
- Monitor performance
- Migrate critical jobs
- 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:
- Solid Cache : A database-backed caching system that rivals Redis for most applications, while offering much greater storage capacity
- Solid Cable : A WebSocket solution that eliminates the need for Redis for Action Cable, making real-time accessible to all
- Solid Queue : A performant and reliable background job manager with no external dependency
Key advantages of the Triumvirate
- ✅ Simplicity : A single technology (your database) instead of three or four
- ✅ Lower costs : No need for expensive external services
- ✅ Easy deployment : Less config, less maintenance
- ✅ Reasonable performance : Sufficient for 80% of Rails apps
- ✅ Modern approach : Takes advantage of fast SSDs and advanced databases
When to adopt the Solid Triumvirate?
Yes, adopt it if:
- You are starting a new Rails 8 project
- You are a startup or small team
- You want to reduce complexity
- Your application does not require extreme performance
Maybe, depending on context:
- You are migrating an existing application
- You have high but not extreme performance needs
- You want to test first before committing
No, keep your current solutions if:
- You have millions of users
- Latency must be < 50ms
- You process tens of thousands of jobs per minute
- You rely on advanced Redis features
The Incremental Approach
You don't have to adopt everything at once! Start with one component:
- Solid Cache first : The easiest to adopt
- Solid Queue next : When you're comfortable with the cache
- Solid Cable last : If you need real-time
Resources to go further
- Official Rails documentation : guides.rubyonrails.org
- GitHub Solid Cache : github.com/rails/solid_cache
- GitHub Solid Cable : github.com/rails/solid_cable
- GitHub Solid Queue : github.com/rails/solid_queue
- 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! 🚀