ruby 97 lines · 4 tabs

Cache-Friendly “Top N” with Materialized View Refresh

Shared by codesnips Jan 2026
4 tabs
class CreateTopSellersMv < ActiveRecord::Migration[7.0]
  def up
    execute <<~SQL
      CREATE MATERIALIZED VIEW top_sellers AS
        SELECT p.id            AS product_id,
               p.name          AS product_name,
               SUM(oi.quantity) AS total_sold,
               SUM(oi.quantity * oi.unit_price_cents) AS revenue_cents
        FROM order_items oi
        JOIN products p ON p.id = oi.product_id
        WHERE oi.created_at >= NOW() - INTERVAL '30 days'
        GROUP BY p.id, p.name
        ORDER BY total_sold DESC
        LIMIT 100
      WITH DATA;
    SQL

    # Required for REFRESH ... CONCURRENTLY
    execute "CREATE UNIQUE INDEX index_top_sellers_on_product_id ON top_sellers (product_id);"
  end

  def down
    execute "DROP MATERIALIZED VIEW IF EXISTS top_sellers;"
  end
end
4 files · ruby Explain with highlit

Computing a "top N" list — best-selling products, highest-scoring players — on every request is expensive because it forces the database to scan, aggregate, and sort large tables repeatedly. A materialized view solves this by precomputing the aggregate once and storing the result as a physical table that reads are cheap against. This snippet shows the full loop in Rails: a migration that defines the view, a read-only model that queries it, and a background job that refreshes it on a schedule.

The CreateTopSellersMV migration defines top_sellers with execute because materialized views are not part of the ActiveRecord schema DSL. The aggregation joins order_items to products, sums quantities, and keeps only the top 100 rows via ORDER BY ... LIMIT. Crucially it also creates a UNIQUE INDEX on product_id; that index is not an optimization here — it is a hard prerequisite for REFRESH MATERIALIZED VIEW CONCURRENTLY, which Postgres refuses to run without one.

In TopSeller model, the class points at the view as its table_name and calls readonly! in an after_initialize hook so no code accidentally attempts to write to a view. The ranked scope orders by the precomputed total_sold and applies a LIMIT, and refresh! wraps the raw REFRESH ... CONCURRENTLY statement. Concurrent refresh is the key trade-off: it rebuilds the view without taking an ACCESS EXCLUSIVE lock, so readers keep seeing the old snapshot until the new one is ready — at the cost of doing more work and requiring that unique index.

The RefreshTopSellersJob is a Sidekiq worker that calls TopSeller.refresh! and then bumps a cache key so any application-level cache of the rendered list is invalidated in lockstep. It guards against overlap using a Redis SETNX lock, because two concurrent REFRESH CONCURRENTLY calls on the same view would serialize and waste resources.

The pattern fits data that tolerates slight staleness: the view is eventually consistent with the base tables, refreshed every few minutes rather than per write. Pitfalls include the unique-index requirement, the disk and CPU cost of frequent refreshes, and remembering to schedule the job. When freshness must be exact, a plain query or a trigger-maintained summary table is more appropriate instead.


Related snips

Share this code

Here's the card — post it anywhere.

Cache-Friendly “Top N” with Materialized View Refresh — share card
Link copied