-
Notifications
You must be signed in to change notification settings - Fork 0
Materialized View Data Freshness Design Decisions
Status: Accepted design, pre-implementation · Date: 2026-06-20 Applies to:
activerecord-materializedbootstrap, read path, and incremental view maintenance (IVM)
This document records the agreed design for how materialized views (MVs) are provisioned, kept fresh, and read — with the explicit goal of being safe to launch against a large production database. It supersedes the original "lazy full bootstrap on first read" behavior.
The original implementation builds an MV lazily on the first read of a missing
cache table: it runs the source query twice (once to infer the table schema,
once for data), materializes every row into Ruby memory, and writes them
back with one large insert_all. On a big database this is dangerous:
- the expensive analytical query runs synchronously inside a web request;
- the entire result set is buffered in process memory — a likely OOM;
- a single multi-million-row
INSERTis a long write transaction (and can exceed MySQLmax_allowed_packet); - concurrent first-readers raise while a build is in progress.
We want the opposite posture: a full scan of the base tables never happens by accident. Provisioning is a migration; freshness is maintained incrementally; reads are always correct; and the developer only declares the view and its dependencies.
Goals
- Never trigger a full MV rebuild implicitly (migration, deploy, read, or scheduled refresh). Full materialization is an explicit, intentional act.
- Reads are always correct, even against a cold or partially-warm view.
- Writes to base tables drive incremental maintenance using the most efficient algorithm that is provably correct for the view's aggregates.
- The MV table is provisioned and schema-migrated through normal Rails migrations.
- Developer surface stays minimal: declare
materialized_from+depends_on; everything else is automatic.
Non-goals
- Maintaining arbitrary SQL with zero base re-scan. That is provably impossible for some aggregates (see §4); we degrade gracefully, never to a full rebuild.
- Strong (synchronous) read-your-write consistency on the MV. The model is eventual consistency with a correct read-through fallback.
The request is always correct + maximally efficient + fully general, with no full rebuild. For arbitrary SQL the first three cannot hold simultaneously — a known result, not a shortcoming:
| Aggregate class | Insert | Delete | Maintenance |
|---|---|---|---|
Distributive — COUNT, SUM, COUNT(*)
|
self-maintainable | self-maintainable | apply signed deltas |
Algebraic — AVG, variance |
self-maintainable (keep SUM+COUNT) |
self-maintainable | derive from maintained components |
Non-distributive — MIN, MAX
|
self-maintainable | not self-maintainable | scoped recompute of affected group (or auxiliary top-k) |
Holistic — COUNT(DISTINCT), median |
needs multiplicity state | needs multiplicity state | multiplicity table or scoped recompute |
Resolution principle: classify each view's projection, use true delta maintenance where it is provably correct, and degrade to scoped partition recompute (re-run the query for only the affected group keys) where it is not. "No base re-scan" is not always achievable; "no full rebuild" always is. That distinction is the backbone of this design.
Every downstream behavior reduces to one piece of state we do not have today:
which slices of the view are authoritative right now. We introduce a
partition-state map, keyed by the view's GROUP BY key, with three states:
- absent — never materialized (e.g. immediately after the migration creates an empty table);
- fresh — the MV row is authoritative;
- dirty — backing data changed; the MV row is stale pending maintenance.
Every lens becomes a transition over this map:
read (absent|dirty) write to partition
┌─────────┐ ─────────────────────▶ ┌─────────┐ ───────────▶ ┌─────────┐
│ absent │ read-through to base │ dirty │ enqueue IVM │ fresh │
└─────────┘ + enqueue maintenance └─────────┘ ◀─────────── └─────────┘
▲ maintain
└────────────────── never: full rebuild ──────────────────┘
A side table records partition freshness:
ar_materialized_view_partitions
view_name string (indexed with partition_key, unique)
partition_key string (serialized GROUP BY tuple)
state string (fresh | dirty) -- absent = no row
updated_at datetime
absent is represented by the absence of a row, so cardinality is bounded
by the number of partitions that have been touched, not the whole key space.
A view carries a coarse mode so the side table never has to enumerate every possible group:
- cold — few/no partitions authoritative; track the small fresh set as it warms.
- warm — all partitions authoritative; track only the small dirty set.
An explicit rebuild or a warm-up run promotes cold → warm and clears the set.
This is demand-driven partial materialization, related to the warehouse
"summary-delta" model (Mumick, Quass & Mumick, 1997) and to differential
dataflow / DBSP (Budiu et al., 2023), adapted to the application layer.
The MV table is created by a generated Rails migration, not inferred at
runtime. The view generator introspects the relation (relation.limit(0) for
column names, Arel/result metadata for types) and emits a create_table
migration plus the ar_materialized_view_partitions and metadata tables. At
deploy, db:migrate provisions an empty MV table.
Definition changes produce a new ALTER migration (Rails-idiomatic). A startup schema-verify compares the relation's projection against the table and raises with a "regenerate the migration" message on drift — it never auto-alters and never rebuilds.
Decision 4: auto-generate the
create_tablemigration from the relation in theviewgenerator (most Rails-idiomatic), with drift verification at boot.
ensure_materialized! is replaced by a partition-aware read:
- Resolve the partitions the query touches.
- For
freshpartitions, serve from the MV (fast). - For
absent/dirtypartitions, read through to the base relation for that slice, return the correct merged answer, and enqueue scoped maintenance.
Read-through to base for a cold slice runs the slow query, so it is a correctness safety net, not the hot path — which is exactly why the warm-up list (Lens 4) exists. Cold-slice policy is configurable:
-
:read_through— silent base read (default, Decision 3); -
:serve_stale— return whatever is cached (possibly empty) and warm in background; -
:raise— fail loudly (useful in tests / strict environments).
Whole-view queries that don't filter on the group key can only be served from
the MV when the view is warm; otherwise they read through. This is inherent.
Two layers:
-
Summary-delta (true delta IVM) for self-maintainable aggregates. Maintain
per-group
SUM,COUNT, andCOUNT(*); deriveAVG = SUM/COUNT. Apply signed deltas for insert/update/delete using bag (multiset) semantics so counts stay correct under deletes (Blakeley-Larson-Tompa 1986; Gupta-Mumick-Subrahmanian counting algorithm 1993; Griffin-Libkin duplicates 1995). No base re-scan. -
Scoped partition recompute as the correctness floor for non-self-
maintainable cases —
MIN/MAXunder delete,COUNT(DISTINCT), fan-out joins,HAVING. Re-run the query for only the affected group keys (Quass 1996; Palpanas et al. non-distributive aggregates 2002).
The maintainer classifies each projected aggregate and routes per column: distributive/algebraic → delta; non-distributive/holistic → scoped recompute. A view mixing both uses delta where possible and recompute only for the columns that need it. Neither path ever rebuilds the whole table.
Decision 2: implement the full set now — summary-delta for the self- maintainable aggregates and correct scoped-recompute handling for MIN/MAX/DISTINCT/joins. We do not punt on the hard cases; we route them to the always-correct path.
-
No implicit full scan. Migrations,
refresh_if_stale!, and read paths are guarded to refuse a full base scan. A view that needs materialization stayscoldand reads through until warmed. -
(a) Intentional rebuild. A deliberate, hard-to-fire API
(
Klass.rebuild!(confirm: true)/ rake task) performs the full materialization, with chunkedINSERT … SELECTin the database (no Ruby row round-trip), for recovery or first warm-up. -
(b) Warm-up list. A configurable set of representative queries
(
warm_up { [Klass.where(...), ...] }) is run at boot/deploy to populate the hot partitions on purpose. This is what keeps Lens 2's read-through from firing in practice, and doubles as a smoke-verify of the view.
The write backbone already exists: after_*_commit → MaintenanceDeltaBuilder
→ accumulated partition keys in MaintenanceStore → scheduled maintenance.
Layered on the freshness map, the target "win" falls out for free:
The server boots with an empty MV. A background write to partition P maintains/populates P and marks it
fresh. The user then reads P from the MV — fast and correct — while the rest of the view is stillabsentand harmlessly reads through.
The developer writes only materialized_from + depends_on; freshness, read-
through, maintenance routing, and warm-up are automatic.
class SalesSummary < ActiveRecord::Materialized::View
self.table_name = "mv_sales_summary"
materialized_from { ... } # source ActiveRecord::Relation
depends_on LineItem, Order # write tracking
cold_read :read_through # :read_through (default) | :serve_stale | :raise
warm_up { [where("revenue > 0")] } # representative queries run at boot
end
# Provisioning (idiomatic Rails):
# bin/rails generate activerecord_materialized:view SalesSummary
# bin/rails db:migrate # creates empty MV + partition/metadata tables
# Intentional, never-implicit full materialization:
# SalesSummary.rebuild!(confirm: true)
# bin/rails materialized:rebuild VIEW=SalesSummaryEach phase is an independent issue / PR.
-
P1 — Freshness core.
ar_materialized_view_partitionstable; partition state machine; read-through path with:read_throughdefault; the "never full rebuild" guard; explicitrebuild!(confirm:). -
P2 — Migration-provisioned schema. Generator emits the
create_tablemigration from the relation; boot-time drift verification. - P3 — Summary-delta IVM. Self-maintainable SUM/COUNT/AVG with signed deltas and bag semantics; per-column classification; scoped-recompute fallback for MIN/MAX/DISTINCT/joins.
-
P4 — Warm-up.
warm_upDSL + boot hook; chunked in-databaseINSERT … SELECTfor rebuild/warm.
-
Per-partition freshness storage: dedicated
ar_materialized_view_partitionsside table with cold/warm promotion to cap cardinality. - Delta-IVM scope: implement everything — true delta for self-maintainable aggregates and correct scoped-recompute for the rest; no punting.
-
Cold-slice read default:
:read_through(silent base read). -
Migration UX: auto-generate the
create_tablemigration from the relation in theviewgenerator, with boot-time drift verification.
- Blakeley, Larson, Tompa. Efficiently Updating Materialized Views. SIGMOD 1986.
- Gupta, Mumick, Subrahmanian. Maintaining Views Incrementally. SIGMOD 1993. (counting algorithm; DRed for aggregates/recursion)
- Griffin, Libkin. Incremental Maintenance of Views with Duplicates. SIGMOD 1995.
- Colby, Griffin, Libkin, Mumick, Trickey. Algorithms for Deferred View Maintenance. SIGMOD 1996.
- Quass, Gupta, Mumick, Widom. Making Views Self-Maintainable for Data Warehousing. PDIS 1996.
- Quass. Maintenance Expressions for Views with Aggregation. 1996.
- Mumick, Quass, Mumick. Maintenance of Data Cubes and Summary Tables in a Warehouse. SIGMOD 1997. (summary-delta)
- Palpanas, Sidle, Cochrane, Pirahesh. Incremental Maintenance for Non-Distributive Aggregate Functions. VLDB 2002. (MIN/MAX)
- Ahmad, Kennedy, Koch, Nikolic. DBToaster: Higher-order Delta Processing for Dynamic, Frequently Fresh Views. VLDB 2012.
- Budiu, McSherry, Ryzhyk, Tannen. DBSP: Automatic Incremental View Maintenance for Rich Query Languages. VLDB 2023.
- Partition-key serialization for composite/typed group keys (collation, null handling).
- Eviction/TTL for
freshpartitions incoldmode to bound the side table on very high-cardinality views. - Maintenance under base-table schema changes (column rename/drop on a dependency).
- Multi-process coordination of warm-up and rebuild (advisory locks) so only one worker materializes.