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.
Files changed (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +93 -0
  3. data/README.md +139 -324
  4. data/lib/activerecord/materialized/aggregate_analysis.rb +10 -31
  5. data/lib/activerecord/materialized/async_refresher.rb +18 -27
  6. data/lib/activerecord/materialized/cache_table_schema.rb +53 -34
  7. data/lib/activerecord/materialized/change_source.rb +25 -0
  8. data/lib/activerecord/materialized/cold_read.rb +1 -10
  9. data/lib/activerecord/materialized/column_type_inference.rb +167 -0
  10. data/lib/activerecord/materialized/configurable_table_name.rb +45 -0
  11. data/lib/activerecord/materialized/configuration.rb +104 -29
  12. data/lib/activerecord/materialized/connection_routing.rb +29 -0
  13. data/lib/activerecord/materialized/data_verification_result.rb +41 -0
  14. data/lib/activerecord/materialized/data_verifier.rb +162 -0
  15. data/lib/activerecord/materialized/debezium_envelope.rb +105 -0
  16. data/lib/activerecord/materialized/delta_maintainer.rb +4 -15
  17. data/lib/activerecord/materialized/dependency_registry.rb +43 -35
  18. data/lib/activerecord/materialized/dependency_trackable.rb +7 -14
  19. data/lib/activerecord/materialized/incremental_maintainer.rb +45 -28
  20. data/lib/activerecord/materialized/incremental_refresh.rb +63 -0
  21. data/lib/activerecord/materialized/instrumentation.rb +102 -0
  22. data/lib/activerecord/materialized/maintenance_delta.rb +4 -19
  23. data/lib/activerecord/materialized/maintenance_delta_builder.rb +46 -14
  24. data/lib/activerecord/materialized/maintenance_store.rb +58 -21
  25. data/lib/activerecord/materialized/metadata/maintenance_payload.rb +1 -7
  26. data/lib/activerecord/materialized/metadata/reconciliation.rb +25 -0
  27. data/lib/activerecord/materialized/metadata/schema.rb +19 -41
  28. data/lib/activerecord/materialized/metadata/timestamps.rb +0 -5
  29. data/lib/activerecord/materialized/metadata.rb +41 -25
  30. data/lib/activerecord/materialized/metadata_record.rb +2 -16
  31. data/lib/activerecord/materialized/migration_builder.rb +8 -8
  32. data/lib/activerecord/materialized/module_api.rb +219 -22
  33. data/lib/activerecord/materialized/partition_filter.rb +45 -0
  34. data/lib/activerecord/materialized/partition_keyed_store.rb +72 -0
  35. data/lib/activerecord/materialized/partition_record.rb +2 -16
  36. data/lib/activerecord/materialized/partition_snapshot.rb +79 -0
  37. data/lib/activerecord/materialized/partition_state.rb +47 -46
  38. data/lib/activerecord/materialized/query_expressions.rb +0 -12
  39. data/lib/activerecord/materialized/railtie.rb +15 -3
  40. data/lib/activerecord/materialized/read_router.rb +51 -0
  41. data/lib/activerecord/materialized/reconcile_job.rb +22 -0
  42. data/lib/activerecord/materialized/reconcile_result.rb +36 -0
  43. data/lib/activerecord/materialized/reconciler.rb +79 -0
  44. data/lib/activerecord/materialized/refresh_callbacks.rb +4 -18
  45. data/lib/activerecord/materialized/refresh_job.rb +0 -4
  46. data/lib/activerecord/materialized/refresh_result.rb +2 -10
  47. data/lib/activerecord/materialized/refresh_scheduler.rb +5 -7
  48. data/lib/activerecord/materialized/refresher.rb +35 -50
  49. data/lib/activerecord/materialized/registry.rb +26 -16
  50. data/lib/activerecord/materialized/relation_cache_writer.rb +12 -46
  51. data/lib/activerecord/materialized/schema_verifier.rb +0 -9
  52. data/lib/activerecord/materialized/source_watermark.rb +93 -0
  53. data/lib/activerecord/materialized/source_watermark_record.rb +16 -0
  54. data/lib/activerecord/materialized/summary_delta.rb +1 -17
  55. data/lib/activerecord/materialized/summary_delta_builder.rb +2 -10
  56. data/lib/activerecord/materialized/table_model_registry.rb +1 -8
  57. data/lib/activerecord/materialized/table_swap.rb +106 -0
  58. data/lib/activerecord/materialized/tasks.rb +46 -38
  59. data/lib/activerecord/materialized/version.rb +1 -2
  60. data/lib/activerecord/materialized/view.rb +11 -22
  61. data/lib/activerecord/materialized/view_configuration_class_methods.rb +23 -35
  62. data/lib/activerecord/materialized/view_definition.rb +76 -65
  63. data/lib/activerecord/materialized/view_incremental_class_methods.rb +57 -60
  64. data/lib/activerecord/materialized/view_loader.rb +49 -0
  65. data/lib/activerecord/materialized/view_query_access_class_methods.rb +100 -57
  66. data/lib/activerecord/materialized/view_refresh_policy_class_methods.rb +70 -37
  67. data/lib/activerecord/materialized/write_change.rb +75 -26
  68. data/lib/activerecord/materialized/write_maintenance.rb +73 -0
  69. data/lib/activerecord/materialized/write_outbox.rb +345 -0
  70. data/lib/activerecord/materialized/write_outbox_record.rb +17 -0
  71. data/lib/activerecord/materialized.rb +27 -6
  72. data/lib/generators/activerecord_materialized/install/templates/create_ar_materialized_view_metadata.rb.erb +14 -0
  73. data/lib/generators/activerecord_materialized/install_generator.rb +1 -6
  74. data/lib/generators/activerecord_materialized/migration_generator.rb +3 -9
  75. data/lib/generators/activerecord_materialized/outbox_generator.rb +36 -0
  76. data/lib/generators/activerecord_materialized/templates/materialized_view_migration.rb.erb +5 -1
  77. data/lib/generators/activerecord_materialized/templates/write_outbox_migration.rb.erb +21 -0
  78. data/lib/generators/activerecord_materialized/view_generator.rb +0 -4
  79. metadata +28 -19
  80. data/lib/activerecord/materialized/type_reexports.rb +0 -14
  81. data/lib/activerecord/materialized/view_class.rb +0 -8
  82. 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
- [![Gem Version](https://img.shields.io/gem/v/activerecord-materialized.svg)](https://rubygems.org/gems/activerecord-materialized)
12
+ [![Gem Version](https://img.shields.io/gem/v/activerecord-materialized?logo=rubygems&label=gem)](https://rubygems.org/gems/activerecord-materialized)
13
13
  [![CI](https://github.com/mavrukin/activerecord-materialized/actions/workflows/ci.yml/badge.svg)](https://github.com/mavrukin/activerecord-materialized/actions/workflows/ci.yml)
14
14
  [![Docs](https://img.shields.io/badge/docs-rubydoc.info-blue.svg)](https://rubydoc.info/gems/activerecord-materialized)
15
15
  [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.4-red)](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 don't refresh by hand.
25
- - **Nothing blocks on a rebuild** — refresh is incremental and on-write, never on-read, and a full rebuild only ever happens when you explicitly ask for it.
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
- - [Comparison with native materialized views](#comparison-with-native-materialized-views)
52
- - [Versioning](#versioning)
53
- - [Development](#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
- | Topic | Reference |
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
- ### Practical references
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
- ## Features
62
+ ## Database compatibility
198
63
 
199
- - **Refresh on write**dependency changes schedule background refresh; reads never block on rebuild
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
- ## Gotchas and trade-offs
217
-
218
- | Gotcha | Detail |
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 | [![MySQL](https://github.com/mavrukin/activerecord-materialized/actions/workflows/db-mysql.yml/badge.svg?branch=main)](https://github.com/mavrukin/activerecord-materialized/actions/workflows/db-mysql.yml) |
69
+ | PostgreSQL 16 | [![PostgreSQL](https://github.com/mavrukin/activerecord-materialized/actions/workflows/db-postgres.yml/badge.svg?branch=main)](https://github.com/mavrukin/activerecord-materialized/actions/workflows/db-postgres.yml) |
70
+ | SQLite 3 | [![SQLite](https://github.com/mavrukin/activerecord-materialized/actions/workflows/db-sqlite.yml/badge.svg?branch=main)](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
- Sources must be `ActiveRecord::Relation` objects built with standard query APIs and Arel — not raw SQL strings. Extract complex relations to a module or class method when a view definition grows large (see `spec/support/view_sources.rb` and `benchmark/support/source_relations.rb` in this repo).
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
- ### Incremental maintenance (default)
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
- For `GROUP BY` aggregate views, no extra configuration is required. The gem:
160
+ ---
334
161
 
335
- 1. Inspects the `materialized_from` relation to derive maintenance partition keys (`GROUP BY` columns).
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
- Optional overrides when you need explicit control:
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
- ```ruby
342
- class SalesSummary < ActiveRecord::Materialized::View
343
- incremental_keys :category # override inferred GROUP BY keys
344
- refresh_mode :full # opt out of incremental maintenance
345
- # incremental_from { ... } # optional: override auto-scoped maintenance relation
346
- end
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
- ## Configuration
186
+ ## Research background
352
187
 
353
- ```ruby
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
- ## API reference
371
-
372
- ### Class methods
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
- ```bash
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
- ## Benchmark results
206
+ ### Systems & dataflow approaches
429
207
 
430
- 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):
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
- | Query | Source relation | MV read | Speedup |
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
- Run locally:
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
- ```bash
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
- See [benchmark/DATA.md](benchmark/DATA.md) for dataset scales and setup details.
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
- ## Versioning
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
- This gem follows [Semantic Versioning](https://semver.org/). Given `MAJOR.MINOR.PATCH`:
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
- - **MAJOR** — incompatible public-API changes (DSL macros, configuration keys, the `View` query surface).
489
- - **MINOR** — backward-compatible features.
490
- - **PATCH** — backward-compatible bug fixes.
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
- 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).
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 + Sorbet RBIs
502
- bin/ci # RuboCop, Sorbet, and the full test suite
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 published at [rubydoc.info/gems/activerecord-materialized](https://rubydoc.info/gems/activerecord-materialized) (generated from YARD doc comments, with types pulled from the Sorbet signatures via `yard-sorbet`). Build it locally with:
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)