Chapter 9: Ruby & Rails: The Scenic Route
The Ruby on Rails ecosystem has, if I may say so, the most polished materialized view integration of any framework examined in this book.
This is largely thanks to the Scenic gem, originally by thoughtbot and now maintained by the scenic-views community — approximately 3,600 stars on GitHub, used in production by Mastodon, Code.org, and Lobsters — which treats materialized views as versioned, migration-managed database objects. Where other frameworks require workarounds, raw SQL migrations, and manual model configuration, Scenic provides generators, version tracking, dependency management, and a zero-downtime update strategy that I consider genuinely elegant. It treats materialized views the way Rails treats everything else: as first-class citizens of the framework, managed through conventions that minimize ceremony and maximize reliability.
Rails 8's removal of Redis from its default application stack — replacing it with SolidQueue for background jobs, SolidCache for caching, and SolidCable for WebSocket connections, all PostgreSQL-backed — makes this chapter's thesis particularly resonant. The framework that popularized Redis-backed background processing has officially decided that Redis is optional. Materialized views complete the picture.
Without Scenic: Raw Rails
Before we examine Scenic, allow me to show the raw approach — materialized views in Rails without any additional gem. This establishes the baseline that Scenic improves upon and serves developers who prefer minimal dependencies.
The migration
class CreateDashboardMetrics < ActiveRecord::Migration[7.2]
def up
execute <<-SQL
CREATE MATERIALIZED VIEW mv_dashboard AS
SELECT
date_trunc('day', created_at)::date AS day,
COUNT(*)::integer AS events,
COUNT(DISTINCT user_id)::integer AS unique_users,
SUM(CASE WHEN event_type = 'purchase'
THEN amount ELSE 0 END)::numeric(13,2) AS revenue
FROM events
WHERE created_at >= CURRENT_DATE - INTERVAL '90 days'
GROUP BY 1
WITH DATA;
CREATE UNIQUE INDEX idx_mv_dashboard_day ON mv_dashboard (day);
SQL
end
def down
execute "DROP MATERIALIZED VIEW IF EXISTS mv_dashboard CASCADE"
end
end The model
class DashboardMetric < ApplicationRecord
self.table_name = 'mv_dashboard'
self.primary_key = 'day'
def readonly?
true
end
def self.refresh(concurrently: true)
keyword = concurrently ? "CONCURRENTLY" : ""
connection.execute(
"REFRESH MATERIALIZED VIEW #{keyword} #{table_name}"
)
end
end self.table_name points ActiveRecord at the materialized view. readonly? prevents accidental writes — ActiveRecord will raise ActiveRecord::ReadOnlyRecord if any code attempts to save, update, or destroy a record. The refresh class method encapsulates the REFRESH SQL with a concurrently option.
Standard ActiveRecord querying works immediately:
DashboardMetric.order(day: :desc).limit(30)
DashboardMetric.where(day: 30.days.ago..Date.today)
DashboardMetric.sum(:revenue) This approach works. For one or two independent materialized views with infrequent schema changes, it is perfectly adequate. But when the query needs to change, the migration becomes a manual DROP-and-recreate operation with index recreation. When multiple materialized views depend on each other, the ordering becomes your responsibility. When you need zero-downtime updates, you're on your own.
Scenic addresses all of this.
The Scenic Gem: Materialized Views Done Right
# Gemfile
gem 'scenic' rails generate scenic:view dashboard_metrics --materialized This single command creates two files:
db/views/dashboard_metrics_v01.sql — the SQL definition, in a standalone file with full syntax highlighting:
SELECT
date_trunc('day', created_at)::date AS day,
COUNT(*)::integer AS events,
COUNT(DISTINCT user_id)::integer AS unique_users,
SUM(CASE WHEN event_type = 'purchase'
THEN amount ELSE 0 END)::numeric(13,2) AS revenue
FROM events
WHERE created_at >= CURRENT_DATE - INTERVAL '90 days'
GROUP BY 1 Pure SQL. No Ruby string interpolation. No heredoc escaping. You can copy this file directly into psql and test it. Your editor provides full SQL syntax highlighting. Your code reviewer can read it without mentally parsing Ruby.
db/migrate/XXXX_create_dashboard_metrics.rb — the migration:
class CreateDashboardMetrics < ActiveRecord::Migration[7.2]
def change
create_view :dashboard_metrics, materialized: true
add_index :dashboard_metrics, :day, unique: true
add_index :dashboard_metrics, :day, order: { day: :desc },
name: 'idx_dashboard_metrics_day_desc'
end
end create_view with materialized: true — Scenic handles the CREATE MATERIALIZED VIEW statement, reading the SQL from the versioned file. Standard Rails add_index calls add indexes exactly as they would for a regular table. The UNIQUE index enables REFRESH CONCURRENTLY.
The model
rails generate scenic:model dashboard_metric --materialized Generates:
class DashboardMetric < ApplicationRecord
def self.refresh
Scenic.database.refresh_materialized_view(
table_name,
concurrently: false,
cascade: false
)
end
end The refresh method is generated automatically. In production, you'll want concurrently: true (once the UNIQUE index exists) and potentially cascade: true if this view feeds other materialized views.
Querying is standard ActiveRecord — the model doesn't know or care that it's backed by a materialized view rather than a table:
# In a controller or API endpoint
recent = DashboardMetric.order(day: :desc).limit(30)
# Scopes work naturally
class DashboardMetric < ApplicationRecord
scope :last_30_days, -> { where(day: 30.days.ago..Date.today) }
scope :with_revenue, -> { where('revenue > 0') }
end
# Associations work if the MV includes foreign keys
class DashboardMetric < ApplicationRecord
belongs_to :category, optional: true
end
# Works with Jbuilder, ActiveModel Serializers, or any API layer Cascade refresh for dependent views
When materialized views depend on each other — a daily metrics view feeding a weekly rollup — Scenic's cascade option handles the ordering:
class DailyMetric < ApplicationRecord
def self.refresh
Scenic.database.refresh_materialized_view(
table_name,
concurrently: true,
cascade: true # refresh dependent views after this one
)
end
end With cascade: true, refreshing the daily view automatically refreshes any views that depend on it. The dependency graph is resolved by Scenic — you declare the relationships, and the refresh cascades in the correct order.
Updating Views: Versioning and the Side-by-Side Strategy
When the defining query needs to change — a new column, a modified aggregation, an updated WHERE clause — generate a new version:
rails generate scenic:view dashboard_metrics --materialized Scenic creates db/views/dashboard_metrics_v02.sql (your new query definition) and a migration:
class UpdateDashboardMetricsToVersion2 < ActiveRecord::Migration[7.2]
def change
update_view :dashboard_metrics,
version: 2,
revert_to_version: 1,
materialized: true
end
end The revert_to_version parameter ensures that rails db:rollback restores the previous version cleanly. Your version history lives in db/views/ — auditable, diffable, reviewable in pull requests. You can see exactly what changed between v1 and v2 with a standard diff.
Scenic automatically reapplies indexes when a view is updated. Other approaches — including the raw Rails migration pattern — silently drop indexes when the view is recreated. The developer discovers this in production, usually when query performance degrades and the EXPLAIN output reveals sequential scans where index scans should be. Scenic prevents this by tracking indexes and reapplying them after the view is recreated.
The side_by_side strategy
Standard materialized view updates require dropping the old view and creating the new one. During the refresh that populates the new view, it is unavailable for reads — potentially for minutes if the defining query is complex. For production applications serving traffic, this creates an unacceptable downtime window.
Scenic's side_by_side strategy solves this:
class UpdateDashboardMetricsToVersion2 < ActiveRecord::Migration[7.2]
def change
update_view :dashboard_metrics,
version: 2,
revert_to_version: 1,
materialized: { side_by_side: true }
end
end The process: Scenic creates the new version of the view under a temporary name and populates it with fresh data. Reads continue against the old version throughout the population phase. Once the new view is ready, Scenic drops the old version and renames the new one to the production name. The window of unavailability — the instant between the DROP and the RENAME — is negligible compared to the minutes a full refresh from scratch might take.
This is, to my knowledge, the most elegant near-zero-downtime materialized view update mechanism in any web framework. It is the feature that justifies adding Scenic to your Gemfile even for a single materialized view.
Production Scheduling: Three Paths, No Redis Required
SolidQueue: the Rails 8 default
SolidQueue ships with Rails 8 as the default background job backend. It stores jobs in your PostgreSQL database. No Redis. No additional infrastructure. 37signals processes 20 million background jobs per day with it.
For recurring materialized view refresh, SolidQueue uses a YAML configuration:
# config/recurring.yml
production:
refresh_dashboard:
class: RefreshDashboardJob
schedule: "*/15 * * * *"
queue: maintenance class RefreshDashboardJob < ApplicationJob
queue_as :maintenance
def perform
DashboardMetric.refresh
rescue => e
Rails.logger.error("Dashboard MV refresh failed: #{e.message}")
raise # re-raise for SolidQueue's retry mechanism
end
end Standard cron syntax. Standard Active Job class. The refresh runs every 15 minutes, managed by SolidQueue, stored in PostgreSQL. If you're using Rails 8, this is the default path — no additional gems, no configuration beyond the YAML file and the job class.
Good Job: the PostgreSQL-native veteran
Good Job, by Ben Sheldon, predates SolidQueue and offers a more mature feature set for recurring jobs. It is PostgreSQL-backed with built-in cron scheduling and a web dashboard for monitoring:
# Gemfile
gem 'good_job'
# config/initializers/good_job.rb
Rails.application.configure do
config.good_job.enable_cron = true
config.good_job.cron = {
refresh_dashboard: {
cron: "*/15 * * * *",
class: "RefreshDashboardJob",
description: "Refresh dashboard materialized view"
}
}
end Good Job uses PostgreSQL advisory locks for concurrency control. If multiple application instances are running and each attempts the same cron job, only one acquires the lock and executes — the others skip silently. This is the advisory lock pattern from Chapter 5, implemented at the gem level. You get multi-instance safety without writing any coordination code.
Sidekiq: the industry standard
class RefreshDashboardWorker
include Sidekiq::Worker
sidekiq_options retry: 3, queue: :maintenance
def perform
DashboardMetric.refresh
end
end Schedule with sidekiq-cron:
Sidekiq::Cron::Job.create(
name: 'refresh-dashboard',
cron: '*/15 * * * *',
class: 'RefreshDashboardWorker'
) Sidekiq is excellent, battle-tested, and deployed in thousands of production applications. It also requires Redis — which, given the thesis of this book and the direction Rails 8 has taken, is worth noting. SolidQueue and Good Job achieve the same result without the additional infrastructure. The choice is yours; the Waiter has made his preference clear.
pg_cron: the simplest option
If your materialized view refresh doesn't require application logic — no error routing to exception trackers, no conditional checks, no integration with deployment pipelines — pg_cron eliminates the Ruby code entirely:
SELECT cron.schedule('refresh-dashboard', '*/15 * * * *',
'REFRESH MATERIALIZED VIEW CONCURRENTLY mv_dashboard'); Three lines of SQL. No gem. No worker process. No application deployment required. The refresh runs inside PostgreSQL regardless of whether your Rails application is running, deploying, or restarting.
The Redis-Free Rails Stack
Rails 8 with materialized views provides a complete application architecture that requires exactly one external service: PostgreSQL.
| Concern | Solution | Backed by |
|---|---|---|
| Background jobs | SolidQueue | PostgreSQL. Ships with Rails 8. 20M jobs/day at 37signals. |
| Caching | SolidCache | PostgreSQL. Replaces the Redis-backed cache store. |
| WebSockets | SolidCable | PostgreSQL via LISTEN/NOTIFY. Replaces ActionCable's Redis adapter. |
| Complex query pre-computation | Materialized views | PostgreSQL-native. Managed by Scenic. Refreshed by SolidQueue. |
| Pub/sub for application events | LISTEN/NOTIFY | PostgreSQL-native. No additional infrastructure. |
| Session storage | PostgreSQL | activerecord-session_store gem or an UNLOGGED table per Chapter 6. |
| Search | Full-text search | GIN indexes on materialized views. PostgreSQL-native. Chapter 4's Pattern 3. |
One database. One household. Properly attended to.
Rails 8's removal of Redis from the default stack is not merely a technical decision. It is a statement about where the framework's architects believe the industry is heading. The techniques in this book are not experimental. They are not contrarian opinions dressed up as engineering advice. They are the direction that the most opinionated framework in web development has chosen as its default.
In the next chapter, we attend to Java and Spring Boot — where the ceremony is greater but the materialized view, I assure you, is no less effective.