activerecord-materialized 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +93 -0
- data/README.md +139 -324
- data/lib/activerecord/materialized/aggregate_analysis.rb +10 -31
- data/lib/activerecord/materialized/async_refresher.rb +18 -27
- data/lib/activerecord/materialized/cache_table_schema.rb +53 -34
- data/lib/activerecord/materialized/change_source.rb +25 -0
- data/lib/activerecord/materialized/cold_read.rb +1 -10
- data/lib/activerecord/materialized/column_type_inference.rb +167 -0
- data/lib/activerecord/materialized/configurable_table_name.rb +45 -0
- data/lib/activerecord/materialized/configuration.rb +104 -29
- data/lib/activerecord/materialized/connection_routing.rb +29 -0
- data/lib/activerecord/materialized/data_verification_result.rb +41 -0
- data/lib/activerecord/materialized/data_verifier.rb +162 -0
- data/lib/activerecord/materialized/debezium_envelope.rb +105 -0
- data/lib/activerecord/materialized/delta_maintainer.rb +4 -15
- data/lib/activerecord/materialized/dependency_registry.rb +43 -35
- data/lib/activerecord/materialized/dependency_trackable.rb +7 -14
- data/lib/activerecord/materialized/incremental_maintainer.rb +45 -28
- data/lib/activerecord/materialized/incremental_refresh.rb +63 -0
- data/lib/activerecord/materialized/instrumentation.rb +102 -0
- data/lib/activerecord/materialized/maintenance_delta.rb +4 -19
- data/lib/activerecord/materialized/maintenance_delta_builder.rb +46 -14
- data/lib/activerecord/materialized/maintenance_store.rb +58 -21
- data/lib/activerecord/materialized/metadata/maintenance_payload.rb +1 -7
- data/lib/activerecord/materialized/metadata/reconciliation.rb +25 -0
- data/lib/activerecord/materialized/metadata/schema.rb +19 -41
- data/lib/activerecord/materialized/metadata/timestamps.rb +0 -5
- data/lib/activerecord/materialized/metadata.rb +41 -25
- data/lib/activerecord/materialized/metadata_record.rb +2 -16
- data/lib/activerecord/materialized/migration_builder.rb +8 -8
- data/lib/activerecord/materialized/module_api.rb +219 -22
- data/lib/activerecord/materialized/partition_filter.rb +45 -0
- data/lib/activerecord/materialized/partition_keyed_store.rb +72 -0
- data/lib/activerecord/materialized/partition_record.rb +2 -16
- data/lib/activerecord/materialized/partition_snapshot.rb +79 -0
- data/lib/activerecord/materialized/partition_state.rb +47 -46
- data/lib/activerecord/materialized/query_expressions.rb +0 -12
- data/lib/activerecord/materialized/railtie.rb +15 -3
- data/lib/activerecord/materialized/read_router.rb +51 -0
- data/lib/activerecord/materialized/reconcile_job.rb +22 -0
- data/lib/activerecord/materialized/reconcile_result.rb +36 -0
- data/lib/activerecord/materialized/reconciler.rb +79 -0
- data/lib/activerecord/materialized/refresh_callbacks.rb +4 -18
- data/lib/activerecord/materialized/refresh_job.rb +0 -4
- data/lib/activerecord/materialized/refresh_result.rb +2 -10
- data/lib/activerecord/materialized/refresh_scheduler.rb +5 -7
- data/lib/activerecord/materialized/refresher.rb +35 -50
- data/lib/activerecord/materialized/registry.rb +26 -16
- data/lib/activerecord/materialized/relation_cache_writer.rb +12 -46
- data/lib/activerecord/materialized/schema_verifier.rb +0 -9
- data/lib/activerecord/materialized/source_watermark.rb +93 -0
- data/lib/activerecord/materialized/source_watermark_record.rb +16 -0
- data/lib/activerecord/materialized/summary_delta.rb +1 -17
- data/lib/activerecord/materialized/summary_delta_builder.rb +2 -10
- data/lib/activerecord/materialized/table_model_registry.rb +1 -8
- data/lib/activerecord/materialized/table_swap.rb +106 -0
- data/lib/activerecord/materialized/tasks.rb +46 -38
- data/lib/activerecord/materialized/version.rb +1 -2
- data/lib/activerecord/materialized/view.rb +11 -22
- data/lib/activerecord/materialized/view_configuration_class_methods.rb +23 -35
- data/lib/activerecord/materialized/view_definition.rb +76 -65
- data/lib/activerecord/materialized/view_incremental_class_methods.rb +57 -60
- data/lib/activerecord/materialized/view_loader.rb +49 -0
- data/lib/activerecord/materialized/view_query_access_class_methods.rb +100 -57
- data/lib/activerecord/materialized/view_refresh_policy_class_methods.rb +70 -37
- data/lib/activerecord/materialized/write_change.rb +75 -26
- data/lib/activerecord/materialized/write_maintenance.rb +73 -0
- data/lib/activerecord/materialized/write_outbox.rb +345 -0
- data/lib/activerecord/materialized/write_outbox_record.rb +17 -0
- data/lib/activerecord/materialized.rb +27 -6
- data/lib/generators/activerecord_materialized/install/templates/create_ar_materialized_view_metadata.rb.erb +14 -0
- data/lib/generators/activerecord_materialized/install_generator.rb +1 -6
- data/lib/generators/activerecord_materialized/migration_generator.rb +3 -9
- data/lib/generators/activerecord_materialized/outbox_generator.rb +36 -0
- data/lib/generators/activerecord_materialized/templates/materialized_view_migration.rb.erb +5 -1
- data/lib/generators/activerecord_materialized/templates/write_outbox_migration.rb.erb +21 -0
- data/lib/generators/activerecord_materialized/view_generator.rb +0 -4
- metadata +28 -19
- data/lib/activerecord/materialized/type_reexports.rb +0 -14
- data/lib/activerecord/materialized/view_class.rb +0 -8
- data/lib/activerecord_materialized_types.rb +0 -18
data/README.md
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
**Materialized views for Rails apps on databases that don't have them** — precompute an expensive query into a cache table, refresh it in the background when the underlying data changes, and read it through a transparent ActiveRecord API.
|
|
11
11
|
|
|
12
|
-
[](https://rubygems.org/gems/activerecord-materialized)
|
|
13
13
|
[](https://github.com/mavrukin/activerecord-materialized/actions/workflows/ci.yml)
|
|
14
14
|
[](https://rubydoc.info/gems/activerecord-materialized)
|
|
15
15
|
[](activerecord-materialized.gemspec)
|
|
@@ -18,12 +18,10 @@
|
|
|
18
18
|
|
|
19
19
|
> **Use case:** Your reporting page runs a 12-second join across six tables. Users visit once a day. MySQL has no native materialized views. This gem gives you PostgreSQL-style semantics in application code — writes trigger refresh, reads never pay for it.
|
|
20
20
|
|
|
21
|
-
### Why use this?
|
|
22
|
-
|
|
23
21
|
- **Reads stay fast** — queries hit a small precomputed table, not a multi-second join.
|
|
24
|
-
- **Freshness is automatic** — a write to a `depends_on` model schedules background maintenance; you
|
|
25
|
-
- **Nothing blocks on a rebuild** — refresh is incremental and on-write, never on-read
|
|
26
|
-
- **It's just ActiveRecord** — `where`, `find`, `count`, and scopes work unchanged; an unbuilt view still returns correct results by reading through to the source.
|
|
22
|
+
- **Freshness is automatic** — a write to a `depends_on` model schedules background maintenance; you never refresh by hand.
|
|
23
|
+
- **Nothing blocks on a rebuild** — refresh is incremental and on-write, never on-read; a full rebuild happens only when you explicitly ask for it.
|
|
24
|
+
- **It's just ActiveRecord** — `where`, `find`, `count`, aggregations, and scopes work unchanged; an unbuilt view still returns correct results by reading through to the source (batch iteration like `find_each` needs the view built — see [Gotchas](#gotchas-and-trade-offs)).
|
|
27
25
|
- **It's portable** — works on MySQL, MariaDB, and SQLite, which have no native materialized views.
|
|
28
26
|
|
|
29
27
|
> 🚀 **New here? Start with the [Getting started tutorial](docs/getting-started.md)** — a hands-on, fully tested walkthrough from install to refresh-on-write.
|
|
@@ -37,198 +35,39 @@
|
|
|
37
35
|
## Table of contents
|
|
38
36
|
|
|
39
37
|
- [Why this exists](#why-this-exists)
|
|
38
|
+
- [Database compatibility](#database-compatibility)
|
|
39
|
+
- [Installation](#installation)
|
|
40
|
+
- [Quick start](#quick-start)
|
|
40
41
|
- [How it works](#how-it-works)
|
|
41
42
|
- [Research background](#research-background)
|
|
42
43
|
- [Features](#features)
|
|
43
44
|
- [Gotchas and trade-offs](#gotchas-and-trade-offs)
|
|
44
|
-
- [Installation](#installation)
|
|
45
|
-
- [Getting started tutorial](#getting-started-tutorial)
|
|
46
|
-
- [Quick start](#quick-start)
|
|
47
|
-
- [Configuration](#configuration)
|
|
48
|
-
- [API reference](#api-reference)
|
|
49
|
-
- [Benchmark results](#benchmark-results)
|
|
50
45
|
- [When to use (and when not to)](#when-to-use-and-when-not-to)
|
|
51
|
-
- [
|
|
52
|
-
- [
|
|
53
|
-
- [Development](#
|
|
54
|
-
- [Contributing](#contributing)
|
|
46
|
+
- [Benchmark results](#benchmark-results)
|
|
47
|
+
- [Documentation](#documentation)
|
|
48
|
+
- [Versioning · Development · Contributing · License](#versioning)
|
|
55
49
|
|
|
56
50
|
---
|
|
57
51
|
|
|
58
52
|
## Why this exists
|
|
59
53
|
|
|
60
|
-
Many Rails applications on **MySQL**, **MariaDB**, or **SQLite** hit the same wall:
|
|
61
|
-
|
|
62
|
-
| Symptom | Example |
|
|
63
|
-
|---------|---------|
|
|
64
|
-
| Complex joins + aggregations | `GROUP BY`, `DISTINCT`, correlated subqueries |
|
|
65
|
-
| Seconds per query even with indexes | Dashboards, admin reports, analytics APIs |
|
|
66
|
-
| Read-heavy, write-light | Thousands of reads/day, dozens of writes/day |
|
|
67
|
-
| No native MV support | Unlike PostgreSQL's `CREATE MATERIALIZED VIEW` |
|
|
68
|
-
|
|
69
|
-
**Materialized views** solve this by storing query results as a physical table and refreshing that snapshot when source data changes. High-end databases (PostgreSQL, Oracle, SQL Server) provide this natively. When your database cannot, **activerecord-materialized** implements the same read/refresh split in Ruby — without changing how developers query data.
|
|
70
|
-
|
|
71
|
-
### The problem with refresh-on-read
|
|
72
|
-
|
|
73
|
-
A naive approach refreshes the view on the first read after data changes. That punishes the unlucky user whose visit triggers a 10-second rebuild — and on a large database an implicit full rebuild can be catastrophic. This gem **never rebuilds implicitly**: a full materialization happens only via an explicit `rebuild!(confirm: true)`. Routine freshness is **incremental, on write** (dependency changes schedule partition-local maintenance after commit), and an unbuilt view stays correct via **read-through** to the source query until you build it.
|
|
74
|
-
|
|
75
|
-
---
|
|
76
|
-
|
|
77
|
-
## How it works
|
|
78
|
-
|
|
79
|
-
### Architecture
|
|
80
|
-
|
|
81
|
-
```mermaid
|
|
82
|
-
flowchart TB
|
|
83
|
-
subgraph writes ["Write path — routine maintenance"]
|
|
84
|
-
W["INSERT / UPDATE / DELETE on depends_on model"]
|
|
85
|
-
DT["DependencyTrackable after_*_commit callbacks"]
|
|
86
|
-
DR["DependencyRegistry.publish_write_change!"]
|
|
87
|
-
MS["MaintenanceDeltaBuilder / SummaryDeltaBuilder + MaintenanceStore"]
|
|
88
|
-
RS[RefreshScheduler]
|
|
89
|
-
AR["AsyncRefresher or RefreshJob"]
|
|
90
|
-
RFR["Refresher dispatch"]
|
|
91
|
-
DM["DeltaMaintainer — signed summary deltas (distributive views)"]
|
|
92
|
-
IM["IncrementalMaintainer — scoped delete + re-aggregate (fallback)"]
|
|
93
|
-
W --> DT --> DR --> MS --> RS --> AR --> RFR
|
|
94
|
-
RFR --> DM
|
|
95
|
-
RFR --> IM
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
subgraph bootstrap ["Bootstrap once (explicit rebuild!)"]
|
|
99
|
-
RF["Refresher — RelationCacheWriter INSERT … SELECT + atomic swap"]
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
subgraph reads ["Read path — always fast"]
|
|
103
|
-
Q["SalesSummary queries"]
|
|
104
|
-
CT[("mv_sales_summary cache table")]
|
|
105
|
-
Q --> CT
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
subgraph meta [Metadata]
|
|
109
|
-
MD[("ar_materialized_view_metadata")]
|
|
110
|
-
DM --> CT
|
|
111
|
-
IM --> CT
|
|
112
|
-
DM --> MD
|
|
113
|
-
IM --> MD
|
|
114
|
-
MS --> MD
|
|
115
|
-
RF --> CT
|
|
116
|
-
RF --> MD
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
DM -.->|"applies partition deltas in place"| CT
|
|
120
|
-
IM -.->|"re-aggregates affected partitions"| CT
|
|
121
|
-
RF -.->|"initial snapshot"| CT
|
|
122
|
-
```
|
|
123
|
-
|
|
124
|
-
### Refresh lifecycle
|
|
125
|
-
|
|
126
|
-
1. **Define** a view class with a `materialized_from` block (returning an `ActiveRecord::Relation`) and `depends_on` models.
|
|
127
|
-
2. **Build** — an explicit `rebuild!(confirm: true)` materializes the source relation into the cache table via `RelationCacheWriter` + atomic swap. This is the only full-scan path and never fires implicitly; until it runs, reads fall through to the source (`cold_read :read_through`).
|
|
128
|
-
3. **Write** — any create/update/destroy on a `depends_on` model fires an `after_*_commit` callback (installed by `DependencyTrackable`) that calls `DependencyRegistry.publish_write_change!`.
|
|
129
|
-
4. **Accumulate** — for each affected view, `MaintenanceDeltaBuilder` records affected `GROUP BY` partition keys in `MaintenanceStore` (widens to all partitions when scope is unknown).
|
|
130
|
-
5. **Defer** — `after_*_commit` fires only once the writing transaction commits, so changes are batched naturally and a rolled-back transaction schedules nothing.
|
|
131
|
-
6. **Debounce** — rapid writes coalesce into one maintenance pass (configurable window).
|
|
132
|
-
7. **Maintain** — distributive views (`SUM`/`COUNT`/`COUNT(*)`) apply signed **summary deltas** straight to the affected cache rows without re-reading base rows (`DeltaMaintainer`); everything else (`AVG`, `MIN`, `MAX`, `COUNT(DISTINCT)`, joins, `HAVING`) **re-aggregates only the affected partitions** (`IncrementalMaintainer`). Neither path does DDL or an atomic swap on the hot path.
|
|
133
|
-
8. **Read** — once built, `where`, `find`, `count`, scopes query the cache table directly; reads before maintenance completes return the previous snapshot, reads after see updated partitions. Before the view is built, reads transparently fall through to the source query.
|
|
134
|
-
|
|
135
|
-
### Core components
|
|
136
|
-
|
|
137
|
-
| Component | Role |
|
|
138
|
-
|-----------|------|
|
|
139
|
-
| `ActiveRecord::Materialized::View` | Base model; DSL and query interface |
|
|
140
|
-
| `DependencyTrackable` | Installs `after_*_commit` callbacks on `depends_on` models |
|
|
141
|
-
| `DependencyRegistry` | Maps tables → view classes; publishes commit writes to affected views |
|
|
142
|
-
| `RefreshScheduler` | Dispatches `:async`, `:immediate`, or `:manual` strategies |
|
|
143
|
-
| `AsyncRefresher` | Debounced in-process background maintenance (tests: `flush!`) |
|
|
144
|
-
| `RefreshJob` | Optional ActiveJob wrapper for production workers |
|
|
145
|
-
| `ViewDefinition` | Inspects source relations for `GROUP BY` maintenance keys |
|
|
146
|
-
| `AggregateAnalysis` | Classifies a view's aggregates; decides if it is summary-delta maintainable |
|
|
147
|
-
| `MaintenanceDeltaBuilder` | Maps ActiveRecord change payloads to affected partition keys (scoped recompute) |
|
|
148
|
-
| `SummaryDeltaBuilder` / `SummaryDelta` | Compute and accumulate signed per-partition aggregate deltas (distributive views) |
|
|
149
|
-
| `MaintenanceStore` | Persists pending maintenance (delta or scope) in metadata |
|
|
150
|
-
| `DeltaMaintainer` | Hot path for distributive views: applies summary deltas in place, no base re-read |
|
|
151
|
-
| `IncrementalMaintainer` | Fallback hot path: partition delete + re-aggregate in the existing cache table |
|
|
152
|
-
| `Refresher` | Orchestrates explicit bootstrap/full refresh and dispatches incremental maintenance |
|
|
153
|
-
| `RelationCacheWriter` | Materializes the relation via `INSERT … SELECT`; atomic table swap on full refresh |
|
|
154
|
-
| `QueryExpressions` | Portable Arel helpers (`sum_as`, `count_distinct_as`, …) for view definitions |
|
|
155
|
-
| `Metadata` | Tracks `dirty`, `maintenance_payload`, `last_refreshed_at`, `row_count`, errors |
|
|
156
|
-
|
|
157
|
-
---
|
|
158
|
-
|
|
159
|
-
## Research background
|
|
160
|
-
|
|
161
|
-
This gem applies decades of materialized-view and incremental-maintenance research to the application layer.
|
|
162
|
-
|
|
163
|
-
### Foundational surveys
|
|
164
|
-
|
|
165
|
-
| Topic | Reference |
|
|
166
|
-
|-------|-----------|
|
|
167
|
-
| **Materialized views monograph** | Chirkova & Yang, [*Materialized Views*](https://dsf.berkeley.edu/cs286/papers/mv-fntdb2012.pdf) (Foundations and Trends in Databases, 2012) — definitions, refresh strategies, view selection, query rewriting |
|
|
168
|
-
| **View maintenance taxonomy** | Gupta & Mumick, [*Maintenance of Materialized Views: Problems, Techniques, and Applications*](https://homepages.inf.ed.ac.uk/wenfei/qsx/reading/gupta95maintenance.pdf) (IEEE Data Engineering Bulletin, 1995) — when full vs incremental refresh is appropriate |
|
|
169
|
-
|
|
170
|
-
### Incremental view maintenance
|
|
171
|
-
|
|
172
|
-
| Topic | Reference |
|
|
173
|
-
|-------|-----------|
|
|
174
|
-
| **Warehousing & decoupled sources** | Zhuge et al., [*View Maintenance in a Warehousing Environment*](https://sigmodrecord.org/publications/sigmodRecord/9506/pdfs/568271.223848.pdf) (SIGMOD 1995) — maintaining views when base data lives outside the warehouse |
|
|
175
|
-
| **Higher-order deltas** | Ahmad et al., [*DBToaster: Higher-order Delta Processing for Dynamic, Frequently Fresh Views*](https://arxiv.org/pdf/1207.0137) (VLDB 2012) — recursive finite-differencing for low-latency view refresh |
|
|
176
|
-
| **Factorized IVM (F-IVM)** | Nikolic & Olteanu, [*Incremental View Maintenance with Triple Lock Factorization Benefits*](https://www.cs.ox.ac.uk/dan.olteanu/papers/no-sigmod18.pdf) (SIGMOD 2018) — factorized higher-order maintenance for conjunctive queries and aggregates |
|
|
177
|
-
| **IVM survey (recent)** | Olteanu, [*Recent Increments in Incremental View Maintenance*](https://arxiv.org/pdf/2404.17679) (PODS 2024 Gems) — fine-grained complexity and modern IVM engines |
|
|
178
|
-
|
|
179
|
-
### Systems & dataflow approaches
|
|
54
|
+
Many Rails applications on **MySQL**, **MariaDB**, or **SQLite** hit the same wall: complex joins and aggregations (`GROUP BY`, `DISTINCT`, correlated subqueries) that take seconds per query even with indexes, on read-heavy, write-light data — and no native `CREATE MATERIALIZED VIEW` to lean on.
|
|
180
55
|
|
|
181
|
-
|
|
182
|
-
|-------|-----------|
|
|
183
|
-
| **Differential dataflow** | McSherry et al., [*Differential Dataflow*](https://www.cidrdb.org/cidr2013/Papers/CIDR13_Paper111.pdf) (CIDR 2013) — incremental computation over changing data with multi-version state |
|
|
184
|
-
| **Application-layer precomputation** | Gjengset et al., [*Noria: dynamic, partially-stateful data-flow for high-performance web applications*](https://www.usenix.org/system/files/osdi18-gjengset.pdf) (OSDI 2018) — partially-stateful dataflow that incrementally maintains query results for web backends |
|
|
56
|
+
**Materialized views** solve this by storing query results as a physical table and refreshing that snapshot when source data changes. PostgreSQL, Oracle, and SQL Server provide this natively; when your database can't, **activerecord-materialized** implements the same read/refresh split in Ruby, without changing how you query.
|
|
185
57
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
| Topic | Reference |
|
|
189
|
-
|-------|-----------|
|
|
190
|
-
| **Production reference** | [PostgreSQL: REFRESH MATERIALIZED VIEW](https://www.postgresql.org/docs/current/sql-refreshmaterializedview.html) — `CONCURRENTLY` refresh, separate read/refresh paths |
|
|
191
|
-
| **Benchmark schema** | Leis et al., [*How Good Are Query Optimizers, Really?*](https://dl.acm.org/doi/10.1145/3035918.3064035) (VLDB 2015) — [Join Order Benchmark](https://github.com/gregrahn/join-order-benchmark) used in this repo's benchmark suite |
|
|
192
|
-
|
|
193
|
-
**Design choice:** After a one-time bootstrap, routine refresh uses **incremental view maintenance (IVM)** by default. Following Gupta & Mumick, aggregate views with `GROUP BY` are maintained by recomputing only **affected partitions** (group keys) and merging them into the existing cache table — no table rebuild, no atomic swap on the hot path. Writes on `depends_on` models accumulate partition keys from ActiveRecord change payloads; maintenance deletes stale partition rows and inserts freshly aggregated replacements. Use `refresh_mode :full` when a view cannot be maintained incrementally.
|
|
58
|
+
The trap it avoids is **refresh-on-read**: refreshing on the first read after a change punishes the unlucky user whose visit triggers a multi-second rebuild — and on a large database an implicit full rebuild can be catastrophic. This gem **never rebuilds implicitly**. A full materialization happens only via an explicit `rebuild!(confirm: true)`; routine freshness is **incremental, on write** (dependency changes schedule partition-local maintenance after commit); and an unbuilt view stays correct via **read-through** to the source until you build it.
|
|
194
59
|
|
|
195
60
|
---
|
|
196
61
|
|
|
197
|
-
##
|
|
62
|
+
## Database compatibility
|
|
198
63
|
|
|
199
|
-
-
|
|
200
|
-
- **Transparent ActiveRecord API** — `where`, `find`, `count`, scopes, associations on cache tables
|
|
201
|
-
- **Relation-based sources** — `materialized_from` blocks return `ActiveRecord::Relation` (no raw SQL strings)
|
|
202
|
-
- **Portable aggregations** — `QueryExpressions` helpers build Arel for `SUM`, `COUNT`, `AVG`, etc.
|
|
203
|
-
- **Incremental maintenance by default** — summary-delta IVM for distributive `GROUP BY` views (signed deltas, no base re-scan) with partition-local re-aggregation as the always-correct fallback; no cache-table rebuild on routine refresh
|
|
204
|
-
- **Atomic table swap on bootstrap only** — initial full materialization + rename when the cache is first built or on `refresh_mode :full`
|
|
205
|
-
- **Debounced async refresh** — coalesce rapid writes (PostgreSQL NOTIFY + worker pattern)
|
|
206
|
-
- **ActiveJob integration** — offload refresh to Sidekiq, GoodJob, Solid Queue, etc.
|
|
207
|
-
- **Dependency tracking** — `depends_on` models; ActiveRecord commit callbacks detect writes
|
|
208
|
-
- **Metadata table** — `last_refreshed_at`, `dirty`, `row_count`, `refresh_duration_ms`, errors
|
|
209
|
-
- **Staleness safety net** — optional `max_staleness` + rake tasks for cron-driven refresh
|
|
210
|
-
- **Rails generators** — `activerecord_materialized:install`, `:view`, and `:migration` (cache-table migration inferred from the source relation)
|
|
211
|
-
- **Rake tasks** — `materialized:refresh_all`, `:refresh_stale`, `:rebuild`, `:verify`, `:warm_up`
|
|
212
|
-
- **Benchmark suite** — JOB-schema SQLite database with multi-second analytical queries
|
|
64
|
+
Integration-tested in CI on every push to `main` — real **MySQL** and **PostgreSQL** via Docker containers, **SQLite** in process. Each badge reflects that adapter's integration workflow; see [integration testing](docs/integration-testing.md) to run the matrix locally or add a database.
|
|
213
65
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
|
219
|
-
|--------|--------|
|
|
220
|
-
| **Eventual consistency** | Between a write and background refresh completing, reads return the previous snapshot. Same trade-off as `REFRESH MATERIALIZED VIEW CONCURRENTLY` in PostgreSQL. |
|
|
221
|
-
| **`depends_on` is required** | The gem cannot infer dependencies from a relation. Declare every model (or table) whose writes should trigger refresh. Prefer model classes (`depends_on LineItem`) so commit callbacks are wired automatically. |
|
|
222
|
-
| **Maintenance scope** | Partition keys are taken from ActiveRecord change payloads when possible (`create`/`update`/`destroy` with equality on `GROUP BY` columns). Unbounded writes widen to all partitions (in-place, still no DDL). |
|
|
223
|
-
| **Non-aggregate views** | Views without `GROUP BY` fall back to full refresh (`refresh_mode :full` or atomic swap). Join-heavy maintenance (Larson & Zhou) is not automatic yet. |
|
|
224
|
-
| **Full refresh escape hatch** | `rebuild!(confirm: true)` (or `refresh_mode :full`) rebuilds via atomic swap — use for recovery or non-maintainable views. `refresh!` is always incremental and never rebuilds. |
|
|
225
|
-
| **Table-name-only `depends_on`** | Symbol/string table names work, but refresh-on-write requires a resolvable ActiveRecord model for that table. Raw SQL writes bypass callbacks and will not trigger refresh. |
|
|
226
|
-
| **SQLite vs MySQL in dev** | The benchmark uses SQLite. Production behavior is adapter-agnostic, but test atomic swap on your target database. |
|
|
227
|
-
| **In-process async default** | Default `refresh_dispatcher: :async` uses a background thread. **Use ActiveJob in production** so refresh work runs on job workers, not Puma threads. |
|
|
228
|
-
| **No automatic indexes** | Cache tables are created from query results. Add indexes on cache columns you filter/sort on. |
|
|
229
|
-
| **Storage** | Cache tables duplicate data. Plan disk usage accordingly. |
|
|
230
|
-
| **Nested transactions** | Refresh is scheduled on the transaction where the write occurred; rollback clears pending refreshes for that transaction. |
|
|
231
|
-
| **Bulk writes** | Each committed row to a `depends_on` model runs the maintenance bookkeeping once. Use `:async` (with a non-zero debounce, the default) or `:manual`, not `refresh_debounce 0` or `:immediate`. Pending scope that spans more than `max_tracked_partitions` distinct partitions collapses to one full recompute. `insert_all`/`upsert_all` **bypass** `after_commit`, so the view won't be notified — call `refresh!` (or `mark_dependencies_changed!`) yourself after a callback-skipping bulk load. |
|
|
66
|
+
| Database | CI status |
|
|
67
|
+
|---------------|-----------|
|
|
68
|
+
| MySQL 8 | [](https://github.com/mavrukin/activerecord-materialized/actions/workflows/db-mysql.yml) |
|
|
69
|
+
| PostgreSQL 16 | [](https://github.com/mavrukin/activerecord-materialized/actions/workflows/db-postgres.yml) |
|
|
70
|
+
| SQLite 3 | [](https://github.com/mavrukin/activerecord-materialized/actions/workflows/db-sqlite.yml) |
|
|
232
71
|
|
|
233
72
|
---
|
|
234
73
|
|
|
@@ -249,23 +88,17 @@ bin/rails db:migrate
|
|
|
249
88
|
|
|
250
89
|
---
|
|
251
90
|
|
|
252
|
-
## Getting started tutorial
|
|
253
|
-
|
|
254
|
-
The **[Getting started tutorial](docs/getting-started.md)** is the recommended first read: a hands-on walkthrough that goes from `bundle install` to a view that refreshes itself on write — defining a view, reading through before it's built, building it, querying it, and watching background maintenance update it. Every example in it is executed by the test suite (`spec/docs/getting_started_tutorial_spec.rb`), so the code and the numbers are guaranteed to work.
|
|
255
|
-
|
|
256
|
-
The condensed reference version follows below.
|
|
257
|
-
|
|
258
|
-
---
|
|
259
|
-
|
|
260
91
|
## Quick start
|
|
261
92
|
|
|
93
|
+
The **[Getting started tutorial](docs/getting-started.md)** is the recommended first read — a hands-on walkthrough (every example is executed by the test suite) from `bundle install` to a view that refreshes itself on write. The condensed reference follows.
|
|
94
|
+
|
|
262
95
|
Generate a view model:
|
|
263
96
|
|
|
264
97
|
```bash
|
|
265
98
|
bin/rails generate activerecord_materialized:view SalesSummary
|
|
266
99
|
```
|
|
267
100
|
|
|
268
|
-
Define the view:
|
|
101
|
+
Define the view — a `materialized_from` block returning an `ActiveRecord::Relation` (standard query API + Arel, never a raw SQL string) plus the `depends_on` models whose writes should refresh it:
|
|
269
102
|
|
|
270
103
|
```ruby
|
|
271
104
|
class SalesSummary < ActiveRecord::Materialized::View
|
|
@@ -292,27 +125,21 @@ class SalesSummary < ActiveRecord::Materialized::View
|
|
|
292
125
|
refresh_on_change :async
|
|
293
126
|
refresh_debounce 30.seconds
|
|
294
127
|
max_staleness 12.hours
|
|
295
|
-
|
|
296
|
-
before_refresh { Rails.logger.info("Refreshing #{name}") }
|
|
297
128
|
end
|
|
298
129
|
```
|
|
299
130
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
Provision the (empty) cache table with a migration generated from the relation, so it exists at deploy time:
|
|
131
|
+
Provision the (empty) cache table from the relation, then build the view once — the only full-scan path, never implicit:
|
|
303
132
|
|
|
304
133
|
```bash
|
|
305
134
|
bin/rails generate activerecord_materialized:migration SalesSummary
|
|
306
135
|
bin/rails db:migrate
|
|
307
136
|
```
|
|
308
137
|
|
|
309
|
-
Build the view once (e.g. in a deploy task) — the only full-scan path, never implicit:
|
|
310
|
-
|
|
311
138
|
```ruby
|
|
312
139
|
SalesSummary.rebuild!(confirm: true)
|
|
313
140
|
```
|
|
314
141
|
|
|
315
|
-
Then query like any ActiveRecord model:
|
|
142
|
+
Then query it like any ActiveRecord model:
|
|
316
143
|
|
|
317
144
|
```ruby
|
|
318
145
|
# Served from the mv_sales_summary cache table — never triggers a rebuild.
|
|
@@ -320,132 +147,103 @@ Then query like any ActiveRecord model:
|
|
|
320
147
|
SalesSummary.where("revenue > ?", 10_000).order(revenue: :desc)
|
|
321
148
|
```
|
|
322
149
|
|
|
323
|
-
Refresh strategies:
|
|
150
|
+
Refresh strategies (`refresh_on_change`, or `config.default_refresh_strategy`):
|
|
324
151
|
|
|
325
152
|
| Strategy | Behavior |
|
|
326
153
|
|----------|----------|
|
|
327
154
|
| `:async` (default) | After commit, debounced, via background thread or ActiveJob |
|
|
328
155
|
| `:immediate` | Synchronous refresh on each write (blocks writers) |
|
|
329
|
-
| `:manual` | Mark dirty only; call `refresh!` or rake tasks explicitly |
|
|
156
|
+
| `:manual` | Mark dirty only; call `refresh!` or the rake tasks explicitly |
|
|
330
157
|
|
|
331
|
-
|
|
158
|
+
For `GROUP BY` views, incremental maintenance is automatic — no extra configuration. See [Architecture](docs/architecture.md) for the maintenance internals and override knobs (`incremental_keys`, `refresh_mode :full`, `partition_key_for` for joined-table keys), and the [API reference](docs/api-reference.md) for full configuration.
|
|
332
159
|
|
|
333
|
-
|
|
160
|
+
---
|
|
334
161
|
|
|
335
|
-
|
|
336
|
-
2. Accumulates affected partition keys from dependency writes (via ActiveRecord commit callbacks).
|
|
337
|
-
3. On refresh, deletes and re-inserts only those partitions in the existing cache table.
|
|
162
|
+
## How it works
|
|
338
163
|
|
|
339
|
-
|
|
164
|
+
The library splits the **write path** (maintenance) from the **read path** (always fast). A write to a `depends_on` model — or a change fed through the ingestion API / CDC — schedules incremental, partition-local maintenance *after commit*; reads hit the cache table directly, and an unbuilt view reads through to the source.
|
|
340
165
|
|
|
341
|
-
```
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
166
|
+
```mermaid
|
|
167
|
+
flowchart LR
|
|
168
|
+
subgraph write ["Write path — after commit"]
|
|
169
|
+
direction TB
|
|
170
|
+
W["Write to a depends_on model<br/>(or the ingestion API / CDC)"] --> M["Schedule incremental<br/>maintenance (debounced)"]
|
|
171
|
+
M --> U["Re-aggregate only the<br/>affected partitions"]
|
|
172
|
+
end
|
|
173
|
+
U --> C[("Cache table")]
|
|
174
|
+
subgraph read ["Read path — always fast"]
|
|
175
|
+
direction TB
|
|
176
|
+
Q["where · find · count · scopes"] --> C
|
|
177
|
+
Q -.->|"not built / cold partition"| S["Read through to<br/>the source query"]
|
|
178
|
+
end
|
|
179
|
+
REC["Scheduled reconcile (backstop)<br/>verify vs source → scoped repair"] -.-> C
|
|
347
180
|
```
|
|
348
181
|
|
|
182
|
+
The only full scan is the explicit `rebuild!(confirm: true)`; routine refresh never rebuilds. For the accurate, full architecture — the refresh lifecycle, the component catalog, summary-delta vs scoped-recompute maintenance, and the ingestion/CDC/reconciliation paths — see **[Architecture](docs/architecture.md)**.
|
|
183
|
+
|
|
349
184
|
---
|
|
350
185
|
|
|
351
|
-
##
|
|
186
|
+
## Research background
|
|
352
187
|
|
|
353
|
-
|
|
354
|
-
# config/initializers/activerecord_materialized.rb
|
|
355
|
-
ActiveRecord::Materialized.configure do |config|
|
|
356
|
-
config.default_refresh_strategy = :async
|
|
357
|
-
config.default_refresh_debounce = 30.seconds
|
|
358
|
-
config.refresh_dispatcher = :active_job # :async for in-process thread
|
|
359
|
-
config.refresh_queue_name = :materialized_views
|
|
360
|
-
config.default_max_staleness = 12.hours
|
|
361
|
-
config.default_cold_read_strategy = :read_through # :serve_stale or :raise
|
|
362
|
-
config.atomic_swap_refresh = true
|
|
363
|
-
config.max_tracked_partitions = 1_000 # collapse to a full recompute past this
|
|
364
|
-
config.metadata_table_name = "ar_materialized_view_metadata"
|
|
365
|
-
end
|
|
366
|
-
```
|
|
188
|
+
This gem applies decades of materialized-view and incremental-maintenance research to the application layer.
|
|
367
189
|
|
|
368
|
-
|
|
190
|
+
### Foundational surveys
|
|
369
191
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
| Method | Description |
|
|
375
|
-
|--------|-------------|
|
|
376
|
-
| `rebuild!(confirm: true)` | **Explicit** full materialization via in-database `INSERT … SELECT` (the only full-scan path; never fires implicitly, never buffers rows in Ruby) |
|
|
377
|
-
| `warm_up!` | Materialize the configured `warm_up` partitions ahead of traffic |
|
|
378
|
-
| `refresh!` | Incremental maintenance only (no-op on an unbuilt view); never rebuilds |
|
|
379
|
-
| `refresh_if_stale!` | Incremental maintenance when materialized and stale |
|
|
380
|
-
| `materialized?` | Whether the view has been built (warm) and reads serve from the cache |
|
|
381
|
-
| `dirty?` | Whether a dependency change is pending maintenance |
|
|
382
|
-
| `stale?` | Whether view is dirty or exceeds `max_staleness` |
|
|
383
|
-
| `last_refreshed_at` | Timestamp of last successful refresh |
|
|
384
|
-
| `refreshing?` | Whether a refresh is in progress |
|
|
385
|
-
| `resolved_source` | The current `ActiveRecord::Relation` used for refresh |
|
|
386
|
-
|
|
387
|
-
### DSL
|
|
388
|
-
|
|
389
|
-
| Macro | Description |
|
|
390
|
-
|-------|-------------|
|
|
391
|
-
| `materialized_from { relation }` | Block returning the source `ActiveRecord::Relation` |
|
|
392
|
-
| `depends_on(*models_or_tables)` | Register dependencies; writes trigger refresh |
|
|
393
|
-
| `refresh_on_change(strategy)` | `:async`, `:immediate`, or `:manual` |
|
|
394
|
-
| `refresh_debounce(duration)` | Coalesce rapid writes before refreshing |
|
|
395
|
-
| `refresh_mode(mode)` | `:incremental` (default) or `:full` |
|
|
396
|
-
| `cold_read(strategy)` | Read behavior before the view is built: `:read_through` (default), `:serve_stale`, or `:raise` |
|
|
397
|
-
| `warm_up { [relations] }` | Representative queries whose partitions `warm_up!` materializes ahead of traffic |
|
|
398
|
-
| `incremental_from { relation }` | Optional override for scoped maintenance relation |
|
|
399
|
-
| `incremental_keys(*columns)` | Optional override for inferred `GROUP BY` keys |
|
|
400
|
-
| `max_staleness(duration)` | Optional time-based safety refresh via rake/cron |
|
|
401
|
-
| `before_refresh` / `after_refresh` | Refresh lifecycle callbacks |
|
|
402
|
-
|
|
403
|
-
### QueryExpressions
|
|
404
|
-
|
|
405
|
-
Include or extend `ActiveRecord::Materialized::QueryExpressions` when defining aggregations:
|
|
406
|
-
|
|
407
|
-
| Helper | Arel equivalent |
|
|
408
|
-
|--------|-----------------|
|
|
409
|
-
| `sum_as(attr, as: :name)` | `SUM(...)` |
|
|
410
|
-
| `avg_as(attr, as: :name)` | `AVG(...)` |
|
|
411
|
-
| `count_as(attr, as: :name)` | `COUNT(...)` |
|
|
412
|
-
| `count_distinct_as(attr, as: :name)` | `COUNT(DISTINCT ...)` |
|
|
413
|
-
| `count_all_as(as: :name)` | `COUNT(*)` |
|
|
414
|
-
| `min_as` / `max_as` | `MIN` / `MAX` |
|
|
415
|
-
|
|
416
|
-
### Rake tasks
|
|
192
|
+
| Topic | Reference |
|
|
193
|
+
|-------|-----------|
|
|
194
|
+
| **Materialized views monograph** | Chirkova & Yang, [*Materialized Views*](https://dsf.berkeley.edu/cs286/papers/mv-fntdb2012.pdf) (Foundations and Trends in Databases, 2012) — definitions, refresh strategies, view selection, query rewriting |
|
|
195
|
+
| **View maintenance taxonomy** | Gupta & Mumick, [*Maintenance of Materialized Views: Problems, Techniques, and Applications*](https://homepages.inf.ed.ac.uk/wenfei/qsx/reading/gupta95maintenance.pdf) (IEEE Data Engineering Bulletin, 1995) — when full vs incremental refresh is appropriate |
|
|
417
196
|
|
|
418
|
-
|
|
419
|
-
bin/rails materialized:refresh_all # incremental maintenance pass
|
|
420
|
-
bin/rails materialized:refresh_stale
|
|
421
|
-
bin/rails materialized:rebuild # intentional full materialization (in-DB INSERT … SELECT)
|
|
422
|
-
bin/rails materialized:verify # raise on cache-table schema drift
|
|
423
|
-
bin/rails materialized:warm_up # materialize configured warm_up partitions
|
|
424
|
-
```
|
|
197
|
+
### Incremental view maintenance
|
|
425
198
|
|
|
426
|
-
|
|
199
|
+
| Topic | Reference |
|
|
200
|
+
|-------|-----------|
|
|
201
|
+
| **Warehousing & decoupled sources** | Zhuge et al., [*View Maintenance in a Warehousing Environment*](https://sigmodrecord.org/publications/sigmodRecord/9506/pdfs/568271.223848.pdf) (SIGMOD 1995) — maintaining views when base data lives outside the warehouse |
|
|
202
|
+
| **Higher-order deltas** | Ahmad et al., [*DBToaster: Higher-order Delta Processing for Dynamic, Frequently Fresh Views*](https://arxiv.org/pdf/1207.0137) (VLDB 2012) — recursive finite-differencing for low-latency view refresh |
|
|
203
|
+
| **Factorized IVM (F-IVM)** | Nikolic & Olteanu, [*Incremental View Maintenance with Triple Lock Factorization Benefits*](https://www.cs.ox.ac.uk/dan.olteanu/papers/no-sigmod18.pdf) (SIGMOD 2018) — factorized higher-order maintenance for conjunctive queries and aggregates |
|
|
204
|
+
| **IVM survey (recent)** | Olteanu, [*Recent Increments in Incremental View Maintenance*](https://arxiv.org/pdf/2404.17679) (PODS 2024 Gems) — fine-grained complexity and modern IVM engines |
|
|
427
205
|
|
|
428
|
-
|
|
206
|
+
### Systems & dataflow approaches
|
|
429
207
|
|
|
430
|
-
|
|
208
|
+
| Topic | Reference |
|
|
209
|
+
|-------|-----------|
|
|
210
|
+
| **Differential dataflow** | McSherry et al., [*Differential Dataflow*](https://www.cidrdb.org/cidr2013/Papers/CIDR13_Paper111.pdf) (CIDR 2013) — incremental computation over changing data with multi-version state |
|
|
211
|
+
| **Application-layer precomputation** | Gjengset et al., [*Noria: dynamic, partially-stateful data-flow for high-performance web applications*](https://www.usenix.org/system/files/osdi18-gjengset.pdf) (OSDI 2018) — partially-stateful dataflow that incrementally maintains query results for web backends |
|
|
431
212
|
|
|
432
|
-
|
|
433
|
-
|-------|-----------------|---------|---------|
|
|
434
|
-
| `gender_pairing_stats` | ~7.4s | ~0.3ms | ~21,000× |
|
|
435
|
-
| `company_movie_cross` | ~7.4s | ~0.4ms | ~20,000× |
|
|
436
|
-
| `person_movie_network` | ~13.3s | ~0.7ms | ~20,000× |
|
|
437
|
-
| `cast_coappearance` | ~19.7s | ~0.4ms | ~49,000× |
|
|
213
|
+
### Practical references
|
|
438
214
|
|
|
439
|
-
|
|
215
|
+
| Topic | Reference |
|
|
216
|
+
|-------|-----------|
|
|
217
|
+
| **Production reference** | [PostgreSQL: REFRESH MATERIALIZED VIEW](https://www.postgresql.org/docs/current/sql-refreshmaterializedview.html) — `CONCURRENTLY` refresh, separate read/refresh paths |
|
|
218
|
+
| **Benchmark schema** | Leis et al., [*How Good Are Query Optimizers, Really?*](https://dl.acm.org/doi/10.1145/3035918.3064035) (VLDB 2015) — [Join Order Benchmark](https://github.com/gregrahn/join-order-benchmark) used in this repo's benchmark suite |
|
|
440
219
|
|
|
441
|
-
|
|
442
|
-
bundle install
|
|
443
|
-
JOB_SCALE=xlarge bundle exec rake benchmark:setup # ~few minutes
|
|
444
|
-
bundle exec rake benchmark:slow
|
|
445
|
-
bundle exec rake benchmark:verify_updates # refresh-on-write proof
|
|
446
|
-
```
|
|
220
|
+
**Design choice:** After a one-time bootstrap, routine refresh uses **incremental view maintenance (IVM)** by default. Following Gupta & Mumick, aggregate views with `GROUP BY` are maintained by recomputing only **affected partitions** (group keys) and merging them into the existing cache table — no table rebuild, no atomic swap on the hot path. Use `refresh_mode :full` when a view cannot be maintained incrementally.
|
|
447
221
|
|
|
448
|
-
|
|
222
|
+
---
|
|
223
|
+
|
|
224
|
+
## Features
|
|
225
|
+
|
|
226
|
+
- **Refresh on write** — dependency changes schedule background maintenance; reads never block on a rebuild, and a full rebuild happens only when you explicitly ask for it.
|
|
227
|
+
- **Transparent ActiveRecord API** — `where`, `find`, `count`, scopes, and associations on the cache table; relation-based sources (no raw SQL strings).
|
|
228
|
+
- **Incremental by default** — summary-delta IVM for distributive `GROUP BY` views (signed deltas, no base re-scan) with partition-local re-aggregation as the always-correct fallback; per-partition freshness lets a cold view serve built partitions while the rest read through.
|
|
229
|
+
- **Portable** — MySQL, MariaDB, and SQLite (plus PostgreSQL); portable Arel aggregation helpers via `QueryExpressions`.
|
|
230
|
+
- **Pluggable change sources** — ActiveRecord commit callbacks by default, or feed changes from bulk loads, raw SQL, other services, a CDC stream, or database triggers through the public ingestion API. See [Change sources](docs/change-sources.md).
|
|
231
|
+
- **Self-healing & observable** — scheduled reconciliation bounds staleness by scoped-repairing any drift the change source missed, and the read/refresh/maintenance lifecycle emits `ActiveSupport::Notifications` events. See [Data integrity](docs/reconciliation.md) and [Observability](docs/observability.md).
|
|
232
|
+
- **Production-ready ops** — debounced async refresh, ActiveJob integration, distributed/HA dispatch, `max_staleness`, generators, and rake tasks. See the [API reference](docs/api-reference.md) and [distributed deployment](docs/distributed-deployment.md).
|
|
233
|
+
|
|
234
|
+
---
|
|
235
|
+
|
|
236
|
+
## Gotchas and trade-offs
|
|
237
|
+
|
|
238
|
+
| Gotcha | Detail |
|
|
239
|
+
|--------|--------|
|
|
240
|
+
| **Eventual consistency** | Between a write and background refresh completing, reads return the previous snapshot — the same trade-off as `REFRESH MATERIALIZED VIEW CONCURRENTLY` in PostgreSQL. |
|
|
241
|
+
| **`depends_on` is required** | The gem can't infer dependencies from a relation. Declare every model (or table) whose writes should trigger refresh; prefer model classes so commit callbacks are wired automatically. |
|
|
242
|
+
| **Non-aggregate views** | Views without `GROUP BY` fall back to full refresh (`refresh_mode :full` or atomic swap) — except a `SELECT DISTINCT a, b` of plain columns, whose projection is the partition key, so it is maintained incrementally like `GROUP BY a, b`. |
|
|
243
|
+
| **Cold reads on aggregate views** | Before a view is built, `where`/`find`/`count`/aggregations/`pluck` read through to the source; on a **grouped** view, ordinal finders (`first`/`last`) do too (ordered by the GROUP BY key). `find_each`/`find_in_batches`/`in_batches` and `ids` need the materialized cache (a stable primary key), so they raise `NotMaterializedError` until you `rebuild!(confirm: true)`. A non-grouped view (full-refresh-only) has no group key to order by, so its cold ordinal finders need the view built first. |
|
|
244
|
+
| **Bulk & out-of-band writes** | `insert_all`/`upsert_all` and raw SQL bypass `after_commit`. Feed them through the ingestion API or database triggers, or call `mark_dirty_for_tables!` after a bulk load — see [Change sources](docs/change-sources.md). Pending scope past `max_tracked_partitions` collapses to one full recompute of a warm view, run through the same atomic build-and-swap as `rebuild!` (raise `max_tracked_partitions` to keep bulk writes partition-scoped). |
|
|
245
|
+
| **Indexes / storage** | The cache table is created with an index on the GROUP BY key (unique — it's the partition identity), so incremental maintenance stays partition-local; add your own indexes on any other columns you filter/sort by, and plan disk for the duplicated data. A view whose cache table was built before this index existed picks it up on the next `rebuild!(confirm: true)` — run one after upgrading so its maintenance stays partition-local. |
|
|
246
|
+
| **Dispatcher at scale** | `refresh_dispatcher` auto-resolves to `:active_job` when ActiveJob is loaded, else an in-process thread (single-process-only, warned at boot). Multi-server deployments should confirm `:active_job` and run the periodic backstop from **one** owner — see [distributed deployment](docs/distributed-deployment.md). |
|
|
449
247
|
|
|
450
248
|
---
|
|
451
249
|
|
|
@@ -465,9 +263,7 @@ See [benchmark/DATA.md](benchmark/DATA.md) for dataset scales and setup details.
|
|
|
465
263
|
- Tiny queries where materialization overhead isn't worth it
|
|
466
264
|
- Views where you cannot enumerate all `depends_on` tables
|
|
467
265
|
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
## Comparison with native materialized views
|
|
266
|
+
### Comparison with native materialized views
|
|
471
267
|
|
|
472
268
|
| Capability | PostgreSQL native | activerecord-materialized |
|
|
473
269
|
|------------|-------------------|---------------------------|
|
|
@@ -481,46 +277,65 @@ See [benchmark/DATA.md](benchmark/DATA.md) for dataset scales and setup details.
|
|
|
481
277
|
|
|
482
278
|
---
|
|
483
279
|
|
|
484
|
-
##
|
|
280
|
+
## Benchmark results
|
|
281
|
+
|
|
282
|
+
The included benchmark uses a [Join Order Benchmark](https://github.com/gregrahn/join-order-benchmark)-style schema on SQLite. On the **xlarge** dataset (~2M `cast_info` rows):
|
|
283
|
+
|
|
284
|
+
| Query | Source relation | MV read | Speedup |
|
|
285
|
+
|-------|-----------------|---------|---------|
|
|
286
|
+
| `gender_pairing_stats` | ~7.4s | ~0.3ms | ~21,000× |
|
|
287
|
+
| `company_movie_cross` | ~7.4s | ~0.4ms | ~20,000× |
|
|
288
|
+
| `person_movie_network` | ~13.3s | ~0.7ms | ~20,000× |
|
|
289
|
+
| `cast_coappearance` | ~19.7s | ~0.4ms | ~49,000× |
|
|
485
290
|
|
|
486
|
-
|
|
291
|
+
```bash
|
|
292
|
+
JOB_SCALE=xlarge bundle exec rake benchmark:setup # ~few minutes
|
|
293
|
+
bundle exec rake benchmark:slow
|
|
294
|
+
bundle exec rake benchmark:verify_updates # refresh-on-write proof
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
See [benchmark/DATA.md](benchmark/DATA.md) for dataset scales and setup details.
|
|
298
|
+
|
|
299
|
+
---
|
|
300
|
+
|
|
301
|
+
## Documentation
|
|
302
|
+
|
|
303
|
+
The README covers getting going; the deep material lives in focused guides:
|
|
487
304
|
|
|
488
|
-
- **
|
|
489
|
-
- **
|
|
490
|
-
- **
|
|
305
|
+
- **[Getting started tutorial](docs/getting-started.md)** — hands-on, test-backed walkthrough from install to refresh-on-write.
|
|
306
|
+
- **[Architecture](docs/architecture.md)** — write/read split, refresh lifecycle, component catalog, and how scoped incremental maintenance works (incl. joined-table keys).
|
|
307
|
+
- **[Change sources](docs/change-sources.md)** — the ingestion API, running callback-free, custom adapters, and CDC / Debezium ingestion.
|
|
308
|
+
- **[Out-of-band writes](docs/out-of-band-writes.md)** — capturing raw-SQL / other-service writes with database triggers + an outbox.
|
|
309
|
+
- **[Observability](docs/observability.md)** — the `ActiveSupport::Notifications` event catalog and an example subscriber.
|
|
310
|
+
- **[Data integrity: drift detection & self-healing](docs/reconciliation.md)** — verifying a view against its source and bounding staleness by scoped repair.
|
|
311
|
+
- **[Distributed / high-traffic deployment](docs/distributed-deployment.md)** — job-fleet dispatch, writer/replica routing, running the backstop from one owner.
|
|
312
|
+
- **[API reference](docs/api-reference.md)** — configuration, class methods, the view DSL, `QueryExpressions`, and rake tasks.
|
|
313
|
+
- **[Integration testing](docs/integration-testing.md)** — running the real-database matrix locally or adding an adapter.
|
|
314
|
+
- **[Benchmarks](benchmark/DATA.md)** — dataset scales and setup.
|
|
491
315
|
|
|
492
|
-
|
|
316
|
+
API docs (YARD) are published at [rubydoc.info/gems/activerecord-materialized](https://rubydoc.info/gems/activerecord-materialized).
|
|
493
317
|
|
|
494
318
|
---
|
|
495
319
|
|
|
320
|
+
## Versioning
|
|
321
|
+
|
|
322
|
+
This gem follows [Semantic Versioning](https://semver.org/). Given `MAJOR.MINOR.PATCH`: **MAJOR** for incompatible public-API changes (DSL macros, configuration keys, the `View` query surface), **MINOR** for backward-compatible features, **PATCH** for backward-compatible bug fixes. Until `1.0.0`, the API may still change between minor releases; pin a version if you depend on it. Every change is recorded in [CHANGELOG.md](CHANGELOG.md).
|
|
323
|
+
|
|
496
324
|
## Development
|
|
497
325
|
|
|
498
326
|
```bash
|
|
499
327
|
git clone https://github.com/mavrukin/activerecord-materialized.git
|
|
500
328
|
cd activerecord-materialized
|
|
501
|
-
bin/setup # bundle install + git hooks
|
|
502
|
-
bin/ci # RuboCop
|
|
503
|
-
bundle exec rake benchmark:setup
|
|
504
|
-
bundle exec rake benchmark
|
|
329
|
+
bin/setup # bundle install + git hooks
|
|
330
|
+
bin/ci # RuboCop and the full test suite
|
|
505
331
|
```
|
|
506
332
|
|
|
507
|
-
**API documentation** is
|
|
508
|
-
|
|
509
|
-
```bash
|
|
510
|
-
bundle exec yard doc # generates HTML into doc/
|
|
511
|
-
bundle exec yard server # browse at http://localhost:8808
|
|
512
|
-
```
|
|
513
|
-
|
|
514
|
-
Maintainers: see [RELEASING.md](RELEASING.md) for the gem publishing process.
|
|
515
|
-
|
|
516
|
-
---
|
|
333
|
+
**API documentation** is generated from YARD comments (`@param`/`@return` types authored on the public API): `bundle exec yard doc` (HTML into `doc/`) or `bundle exec yard server` (browse at http://localhost:8808). Maintainers: see [RELEASING.md](RELEASING.md) for the gem publishing process.
|
|
517
334
|
|
|
518
335
|
## Contributing
|
|
519
336
|
|
|
520
337
|
Bug reports and pull requests are welcome at [github.com/mavrukin/activerecord-materialized](https://github.com/mavrukin/activerecord-materialized).
|
|
521
338
|
|
|
522
|
-
---
|
|
523
|
-
|
|
524
339
|
## License
|
|
525
340
|
|
|
526
341
|
MIT © [Michael Avrukin](https://github.com/mavrukin)
|