grain 0.0.1
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 +7 -0
- data/CHANGELOG.md +27 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +447 -0
- data/Rakefile +12 -0
- data/lib/generators/grain/install/install_generator.rb +78 -0
- data/lib/generators/grain/install/templates/create_grain_change_log.rb.erb +22 -0
- data/lib/generators/grain/install/templates/initializer.rb.erb +17 -0
- data/lib/generators/grain/rollup/rollup_generator.rb +39 -0
- data/lib/generators/grain/rollup/templates/rollup.rb.erb +40 -0
- data/lib/generators/grain/table/table_generator.rb +111 -0
- data/lib/generators/grain/table/templates/create_rollup_table.rb.erb +20 -0
- data/lib/grain/backfill.rb +77 -0
- data/lib/grain/cells.rb +66 -0
- data/lib/grain/change_log.rb +100 -0
- data/lib/grain/configuration.rb +34 -0
- data/lib/grain/definition.rb +140 -0
- data/lib/grain/definition_validator.rb +58 -0
- data/lib/grain/dimension.rb +62 -0
- data/lib/grain/discrepancy.rb +48 -0
- data/lib/grain/errors.rb +25 -0
- data/lib/grain/fact.rb +46 -0
- data/lib/grain/join_graph.rb +90 -0
- data/lib/grain/measure.rb +100 -0
- data/lib/grain/migration.rb +93 -0
- data/lib/grain/path.rb +93 -0
- data/lib/grain/projection.rb +116 -0
- data/lib/grain/query.rb +147 -0
- data/lib/grain/query_sql.rb +100 -0
- data/lib/grain/railtie.rb +21 -0
- data/lib/grain/ratio.rb +28 -0
- data/lib/grain/recompute.rb +101 -0
- data/lib/grain/registry.rb +84 -0
- data/lib/grain/rollup.rb +90 -0
- data/lib/grain/rollup_lookup.rb +41 -0
- data/lib/grain/schema.rb +63 -0
- data/lib/grain/triggers.rb +99 -0
- data/lib/grain/type_resolver.rb +83 -0
- data/lib/grain/verification.rb +73 -0
- data/lib/grain/verification_query.rb +134 -0
- data/lib/grain/verification_report.rb +48 -0
- data/lib/grain/version.rb +5 -0
- data/lib/grain/watched_columns.rb +70 -0
- data/lib/grain/worker.rb +109 -0
- data/lib/grain.rb +60 -0
- data/lib/tasks/grain.rake +33 -0
- data/sig/grain.rbs +4 -0
- metadata +125 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 5a98d985852245285e65d2e58f8a2ae02e2c998f58d49ee70ef5f99f3b13e3d9
|
|
4
|
+
data.tar.gz: 7ee5d68d74314f57bbcd0315e4cd9c9d0b2b79d2b2ffd8e3a376d5e09c8c537a
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 48aa997aee1584516efc868aac66685a54d6c6b1caf89a70a10d7e42259023eac2e1c4575dc86fc2e95e42378295bb512081028ea70725897f888ef3bd8774f2
|
|
7
|
+
data.tar.gz: 2d78ba7b8411d9bf0146c8bb6c458958e6f12c7c89c61397e34d0047fe312ac8a3562d0c88b7959b99d569b050385329daf8b38c99e314e15a4da96266f7b8c1
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
## [Unreleased]
|
|
2
|
+
|
|
3
|
+
## [0.0.1] - 2026-08-19
|
|
4
|
+
|
|
5
|
+
First published release. Feature complete for a first pass and tested end to end
|
|
6
|
+
against a live PostgreSQL, but not yet used in a real application, so the API may
|
|
7
|
+
still change.
|
|
8
|
+
|
|
9
|
+
- Rollup definitions: `fact`, `tenant`, `time`, `dimension`, `measure`, `ratio`,
|
|
10
|
+
with dimensions resolved through `belongs_to` chains up to three hops deep.
|
|
11
|
+
- Generators: `grain:install`, `grain:rollup`, `grain:table`. The rollup table's
|
|
12
|
+
shape, its key and every table needing a trigger are all derived from the
|
|
13
|
+
definition.
|
|
14
|
+
- Change capture by database trigger into a single change log, with the previous
|
|
15
|
+
row recorded so the cell a row leaves can still be found. `UPDATE` triggers are
|
|
16
|
+
narrowed to the columns that can move a row between cells, taking the union
|
|
17
|
+
across every rollup that watches a table.
|
|
18
|
+
- `Grain::Worker.drain` claims a batch and rebuilds the cells the changes could
|
|
19
|
+
have touched, claiming and applying in one transaction.
|
|
20
|
+
- `Rollup.verify` compares a rollup against its source and reports wrong, missing
|
|
21
|
+
and extra cells, with `repair: true` to rebuild them.
|
|
22
|
+
- `Rollup.backfill` populates a rollup from existing data, sliced so each slice is
|
|
23
|
+
idempotent and never shows a partial total.
|
|
24
|
+
- Reads: `Rollup.for(...).between(...).by(...)`, with time buckets readable at a
|
|
25
|
+
coarser grain than they are stored, measures combined by their own kind, and
|
|
26
|
+
ratios divided at the grain they are read at.
|
|
27
|
+
- Rake tasks: `grain:verify`, `grain:backfill`, `grain:drain`.
|
data/CODE_OF_CONDUCT.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Code of Conduct
|
|
2
|
+
|
|
3
|
+
"grain" follows [The Ruby Community Conduct Guideline](https://www.ruby-lang.org/en/conduct) in all "collaborative space", which is defined as community communications channels (such as mailing lists, submitted patches, commit comments, etc.):
|
|
4
|
+
|
|
5
|
+
* Participants will be tolerant of opposing views.
|
|
6
|
+
* Participants must ensure that their language and actions are free of personal attacks and disparaging personal remarks.
|
|
7
|
+
* When interpreting the words and actions of others, participants should always assume good intentions.
|
|
8
|
+
* Behaviour which can be reasonably considered harassment will not be tolerated.
|
|
9
|
+
|
|
10
|
+
If you have any concerns about behaviour within this project, please contact us at ["oscardeveloper14@gmail.com"](mailto:"oscardeveloper14@gmail.com").
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 OscarOrtega
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
# Grain
|
|
2
|
+
|
|
3
|
+
**Incrementally maintained pre-aggregates for Rails dashboards — inside your own Postgres.**
|
|
4
|
+
|
|
5
|
+
Declare the grain of an aggregate once. Grain builds the table, keeps it correct as
|
|
6
|
+
your data changes, and can prove it still agrees with the source.
|
|
7
|
+
|
|
8
|
+
```ruby
|
|
9
|
+
class OrderRevenueRollup < Grain::Rollup
|
|
10
|
+
fact LineItem, where: { order: { state: "paid" } }
|
|
11
|
+
|
|
12
|
+
tenant :store_id, via: { order: :store_id }
|
|
13
|
+
time :ordered_on, via: { order: :placed_on }, grain: :day
|
|
14
|
+
dimension :product_id, via: :product_id
|
|
15
|
+
dimension :category_id, via: { product: :category_id }
|
|
16
|
+
|
|
17
|
+
measure :line_count, count: true
|
|
18
|
+
measure :units, sum: "quantity", type: :bigint
|
|
19
|
+
measure :revenue_cents, sum: "quantity * unit_price_cents", type: :bigint
|
|
20
|
+
ratio :average_unit_price, of: :revenue_cents, over: :units
|
|
21
|
+
end
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
OrderRevenueRollup.for(store: current_store)
|
|
26
|
+
.between(1.month.ago, Date.current)
|
|
27
|
+
.by(:category_id)
|
|
28
|
+
.revenue_cents
|
|
29
|
+
# => { 4 => 182_300, 9 => 55_100, nil => 3_400 }
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
> ### Status
|
|
33
|
+
>
|
|
34
|
+
> Version 0.0.1. Feature complete for a first pass and tested end to end against
|
|
35
|
+
> a live PostgreSQL, including the generators, the triggers, the worker,
|
|
36
|
+
> verification, backfilling and reads. **Not yet used in a real application**, so
|
|
37
|
+
> the API may still change. Treat it as something to read and argue with rather
|
|
38
|
+
> than something to put in front of customers this week.
|
|
39
|
+
|
|
40
|
+
## The problem
|
|
41
|
+
|
|
42
|
+
Rails gives you an OLTP schema: normalised, row-oriented, indexed for point
|
|
43
|
+
lookups. A dashboard asks for the opposite — wide aggregations over many rows,
|
|
44
|
+
grouped by several dimensions at once, computed on the fly. In a multi-tenant app
|
|
45
|
+
there is one more dimension multiplying everything.
|
|
46
|
+
|
|
47
|
+
ActiveRecord is not slow. What it does is make it trivial to write a catastrophic
|
|
48
|
+
query and impossible to see it until production falls over.
|
|
49
|
+
|
|
50
|
+
The usual escape routes each fail in a specific way:
|
|
51
|
+
|
|
52
|
+
| Approach | Why it breaks |
|
|
53
|
+
|---|---|
|
|
54
|
+
| Materialized views | `REFRESH` recomputes **everything**, always. One tenant's data changes and you pay to recompute all of them: cost scales with total size, not with what changed. Plain `REFRESH` takes an exclusive lock; `CONCURRENTLY` needs a unique index and is slower still. |
|
|
55
|
+
| `pg_ivm` | Genuinely incremental, but an extension you usually cannot install on managed Postgres. |
|
|
56
|
+
| Fragment or page caching | Key-space explosion. With tenant × dimensions × date range the hit rate collapses, and there is no clean way to know what to invalidate. The deeper mistake is caching the *rendered page* instead of the *aggregate* — aggregates compose, pages do not. |
|
|
57
|
+
| Read replicas | Isolate the load. Do not reduce the computation. |
|
|
58
|
+
| A separate OLAP store | The right answer at large scale, far too heavy for a mid-size Rails app: a CDC pipeline, a duplicated schema, eventual consistency, another system to operate. |
|
|
59
|
+
|
|
60
|
+
Grain sits in the gap between "materialized views plus cron" and "a warehouse
|
|
61
|
+
with CDC".
|
|
62
|
+
|
|
63
|
+
## Install
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
# Gemfile
|
|
67
|
+
gem "grain"
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
```console
|
|
71
|
+
$ bundle install
|
|
72
|
+
$ bin/rails generate grain:install
|
|
73
|
+
$ bin/rails db:migrate
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`grain:install` writes the change log table, the trigger function every watched
|
|
77
|
+
table shares, an initializer, and `app/rollups/`.
|
|
78
|
+
|
|
79
|
+
## Getting started
|
|
80
|
+
|
|
81
|
+
**1. Describe a rollup.**
|
|
82
|
+
|
|
83
|
+
```console
|
|
84
|
+
$ bin/rails generate grain:rollup order_revenue
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
That writes `app/rollups/order_revenue_rollup.rb` with the DSL commented out for
|
|
88
|
+
you to fill in.
|
|
89
|
+
|
|
90
|
+
**2. Build its table.**
|
|
91
|
+
|
|
92
|
+
```console
|
|
93
|
+
$ bin/rails generate grain:table order_revenue
|
|
94
|
+
$ bin/rails db:migrate
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Grain derives the table's shape, its key, and every table that needs a trigger,
|
|
98
|
+
from the definition alone. Run this again whenever the definition changes.
|
|
99
|
+
|
|
100
|
+
**3. Fill it in from the data you already have.**
|
|
101
|
+
|
|
102
|
+
```console
|
|
103
|
+
$ bin/rails grain:backfill ROLLUP=OrderRevenueRollup
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
A new rollup is empty: its triggers only see what happens next. The backfill is
|
|
107
|
+
what makes it true about the past.
|
|
108
|
+
|
|
109
|
+
**4. Keep it fresh.**
|
|
110
|
+
|
|
111
|
+
```console
|
|
112
|
+
$ bin/rails grain:drain
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Run that on a schedule, or call `Grain::Worker.drain` from a job of your own.
|
|
116
|
+
|
|
117
|
+
**5. Read it.**
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
OrderRevenueRollup.for(store: current_store).by(:product_id).revenue_cents
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## The DSL
|
|
124
|
+
|
|
125
|
+
### `fact`
|
|
126
|
+
|
|
127
|
+
The table whose rows are counted and whose columns the measures read.
|
|
128
|
+
|
|
129
|
+
```ruby
|
|
130
|
+
fact LineItem
|
|
131
|
+
fact LineItem, where: { order: { state: "paid" } }
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
`where` takes equality conditions, on the fact's own columns or through one
|
|
135
|
+
`belongs_to`. Anything richer belongs in the source data.
|
|
136
|
+
|
|
137
|
+
### `tenant` — required
|
|
138
|
+
|
|
139
|
+
The column the rollup is partitioned by. Required, because starting the key with
|
|
140
|
+
the most selective column is what keeps reads and recomputes cheap. In practice
|
|
141
|
+
every application with this problem has a natural one: an account, a store, a
|
|
142
|
+
workspace, an organisation.
|
|
143
|
+
|
|
144
|
+
```ruby
|
|
145
|
+
tenant :store_id, via: { order: :store_id }
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### `time` — optional
|
|
149
|
+
|
|
150
|
+
The bucket rows fall into. Only `grain: :day` in this release.
|
|
151
|
+
|
|
152
|
+
```ruby
|
|
153
|
+
time :ordered_on, via: { order: :placed_on }, grain: :day
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Leave it out and the rollup becomes a running total per dimension — a counter
|
|
157
|
+
cache, except one you can verify instead of one that quietly drifts.
|
|
158
|
+
|
|
159
|
+
Timestamps are resolved to a calendar day in an explicit zone (`config.time_zone`,
|
|
160
|
+
UTC by default). Left to the database session, the same row would land in
|
|
161
|
+
different buckets for different callers.
|
|
162
|
+
|
|
163
|
+
### `dimension`
|
|
164
|
+
|
|
165
|
+
```ruby
|
|
166
|
+
dimension :product_id, via: :product_id # a column on the fact
|
|
167
|
+
dimension :category_id, via: { product: :category_id } # one hop
|
|
168
|
+
dimension :currency, via: { order: { store: :currency } } # two hops
|
|
169
|
+
dimension :window_id, via: { order: :window_id }, immutable: true
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
**Dimensions are resolved by following `belongs_to` associations upward from the
|
|
173
|
+
fact, up to three hops.** The restriction is arithmetic, not convenience: through
|
|
174
|
+
a `belongs_to` chain each fact row resolves to exactly one value per dimension and
|
|
175
|
+
therefore lands in exactly one cell. Cross a `has_many` and one row would land in
|
|
176
|
+
several cells at once, silently doubling every count.
|
|
177
|
+
|
|
178
|
+
`immutable: true` is a promise that the path never changes after the row is
|
|
179
|
+
created, and Grain skips watching that table in exchange. Speed for a promise,
|
|
180
|
+
stated in the code where anyone can see it.
|
|
181
|
+
|
|
182
|
+
### `measure`
|
|
183
|
+
|
|
184
|
+
```ruby
|
|
185
|
+
measure :line_count, count: true
|
|
186
|
+
measure :units, sum: "quantity", type: :bigint
|
|
187
|
+
measure :revenue_cents, sum: "quantity * unit_price_cents", type: :bigint
|
|
188
|
+
measure :largest_line, max: "quantity * unit_price_cents", type: :bigint
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
`count`, `sum`, `min` and `max`. Expressions are your own SQL over the fact table,
|
|
192
|
+
which is aliased `f` if you need to qualify a column.
|
|
193
|
+
|
|
194
|
+
`sum`, `min` and `max` require an explicit `type:`. `count` does not, since
|
|
195
|
+
counting rows always yields an integer. The others aggregate arbitrary SQL whose
|
|
196
|
+
type cannot be inferred, and guessing would mean silently rounding your own
|
|
197
|
+
revenue. One extra word is cheap insurance.
|
|
198
|
+
|
|
199
|
+
### `ratio`
|
|
200
|
+
|
|
201
|
+
```ruby
|
|
202
|
+
ratio :average_unit_price, of: :revenue_cents, over: :units
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Stored as its two parts and divided on read, so a rate stays correct at whatever
|
|
206
|
+
grain you read it at instead of being frozen at the one it was computed for.
|
|
207
|
+
Averaging averages is wrong, and a pre-divided rate cannot be rolled up from a day
|
|
208
|
+
to a month.
|
|
209
|
+
|
|
210
|
+
A ratio over nothing is `nil`, not `0`: there is no rate, which is not the same as
|
|
211
|
+
a rate of none.
|
|
212
|
+
|
|
213
|
+
## Verifying
|
|
214
|
+
|
|
215
|
+
This is the point of the gem, not a diagnostic bolted on afterwards. Nobody puts
|
|
216
|
+
an aggregation layer in front of numbers that matter without a way to prove it
|
|
217
|
+
still tells the truth, so the obstacle to clear is never speed — it is doubt.
|
|
218
|
+
|
|
219
|
+
```ruby
|
|
220
|
+
report = OrderRevenueRollup.verify
|
|
221
|
+
report.clean? # => false
|
|
222
|
+
puts report
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
```
|
|
226
|
+
OrderRevenueRollup: 3 cells disagree (1 wrong, 1 missing, 1 extra)
|
|
227
|
+
extra: store_id=1 ordered_on=2020-01-01 product_id=2
|
|
228
|
+
wrong: store_id=1 ordered_on=2026-08-19 product_id=1 — revenue_cents 999999 should be 1000
|
|
229
|
+
missing: store_id=1 ordered_on=2026-08-19 product_id=2
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Three kinds, and all three matter:
|
|
233
|
+
|
|
234
|
+
- **wrong** — both sides have the cell, the numbers differ.
|
|
235
|
+
- **missing** — the source has a cell the rollup never got.
|
|
236
|
+
- **extra** — the rollup still holds a cell whose last source row went away. This
|
|
237
|
+
is the one a design built on upserts can never find, because there is nothing
|
|
238
|
+
left to upsert against.
|
|
239
|
+
|
|
240
|
+
Scope it, and repair what it finds:
|
|
241
|
+
|
|
242
|
+
```ruby
|
|
243
|
+
OrderRevenueRollup.verify(tenant: current_store.id)
|
|
244
|
+
OrderRevenueRollup.verify(between: 1.week.ago.to_date..Date.current)
|
|
245
|
+
OrderRevenueRollup.verify(repair: true)
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Repair rebuilds all three kinds uniformly, because recomputing a cell already
|
|
249
|
+
knows to delete it when the source yields nothing.
|
|
250
|
+
|
|
251
|
+
```console
|
|
252
|
+
$ bin/rails grain:verify # exits non-zero if anything disagrees
|
|
253
|
+
$ bin/rails grain:verify VERIFY_REPAIR=1
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
The non-zero exit is so this can gate a build. A rollup that quietly disagrees
|
|
257
|
+
with its source is worse than one that is obviously broken.
|
|
258
|
+
|
|
259
|
+
A full verification is an aggregate scan of the source: a maintenance operation,
|
|
260
|
+
not something to run per request. Scope it on a large rollup.
|
|
261
|
+
|
|
262
|
+
## Reading
|
|
263
|
+
|
|
264
|
+
```ruby
|
|
265
|
+
mine = OrderRevenueRollup.for(store: current_store)
|
|
266
|
+
|
|
267
|
+
mine.revenue_cents # => 1400
|
|
268
|
+
mine.by(:product_id).revenue_cents # => { 1 => 900, 2 => 500 }
|
|
269
|
+
mine.by(ordered_on: :month).revenue_cents # => { Sat 01 Aug 2026 => 1000, ... }
|
|
270
|
+
mine.between(1.month.ago, Date.current).largest_line
|
|
271
|
+
mine.by(:product_id).average_unit_price # => { 1 => 100.0, 2 => 500.0 }
|
|
272
|
+
mine.by(:product_id).rows # every measure and ratio at once
|
|
273
|
+
mine.by(:product_id).to_h # keyed by group
|
|
274
|
+
mine.sql # the statement, for when you want to look
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
- **`for`** filters any dimension. Values may be ids, ActiveRecord objects,
|
|
278
|
+
arrays, or `nil` (which matches a null coordinate).
|
|
279
|
+
- **`between`** takes two dates or a range.
|
|
280
|
+
- **`by`** groups. Coarsen the time bucket with `by(ordered_on: :month)` —
|
|
281
|
+
`:day`, `:week`, `:month`, `:quarter`, `:year`.
|
|
282
|
+
- Any dimension left out of `by` is aggregated away. That is the property the
|
|
283
|
+
whole design rests on: a day rolls up into a month by addition, so one stored
|
|
284
|
+
grain answers questions at every coarser one.
|
|
285
|
+
- Each measure combines by its own kind. Counts and sums add; an extreme collapses
|
|
286
|
+
to the extreme of the extremes, so the largest line in August is August's
|
|
287
|
+
largest line, not the total of every day's largest.
|
|
288
|
+
- Narrowing returns a new query, so a base query can be handed around and reused.
|
|
289
|
+
- Results come back typed — `Date` and `Integer`, not the driver's strings.
|
|
290
|
+
|
|
291
|
+
## Backfilling
|
|
292
|
+
|
|
293
|
+
```ruby
|
|
294
|
+
OrderRevenueRollup.backfill
|
|
295
|
+
OrderRevenueRollup.backfill(from: Date.new(2026, 3, 1), pause: 0.2) do |slice, i, total|
|
|
296
|
+
Rails.logger.info("grain: slice #{i}/#{total} #{slice}")
|
|
297
|
+
end
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
```console
|
|
301
|
+
$ bin/rails grain:backfill ROLLUP=OrderRevenueRollup FROM=2026-03-01 PAUSE=0.2
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
The work is sliced, not batched by row. Rows belonging to one cell are scattered
|
|
305
|
+
through the fact table, so a batch of rows would have to add to cells already
|
|
306
|
+
written — the delta problem again with none of its safeguards. A slice (one day,
|
|
307
|
+
or one tenant for a rollup with no time bucket) is rebuilt whole instead. Three
|
|
308
|
+
things follow:
|
|
309
|
+
|
|
310
|
+
- It is idempotent. Running it twice changes nothing.
|
|
311
|
+
- No cell is ever visible holding a partial total.
|
|
312
|
+
- It needs no coordination with the worker. Recompute is complete rather than
|
|
313
|
+
incremental, so it cannot be half applied or applied out of order.
|
|
314
|
+
|
|
315
|
+
Slices are the distinct values that actually have data, not a min-to-max range, so
|
|
316
|
+
gaps are skipped. Finding them reads the fact table once, which is the expensive
|
|
317
|
+
part of a backfill.
|
|
318
|
+
|
|
319
|
+
Resuming is manual and deliberate: slices are processed in order and each is
|
|
320
|
+
reported, so `FROM=` the last one reported picks up where it stopped. Repeating a
|
|
321
|
+
slice is harmless either way.
|
|
322
|
+
|
|
323
|
+
## How it works
|
|
324
|
+
|
|
325
|
+
1. **A physical rollup table**, not a materialized view, keyed on
|
|
326
|
+
`(tenant, time bucket, dimensions…)` with the measures pre-aggregated. Reads
|
|
327
|
+
filter on a prefix of that key, and so does every scoped recompute, so both
|
|
328
|
+
ride the primary key index.
|
|
329
|
+
2. **One trigger per source table**, never one per rollup: several rollups can
|
|
330
|
+
read the same table and triggers must not multiply with them. A trigger records
|
|
331
|
+
that a row changed and nothing more.
|
|
332
|
+
3. **A change log** holding `(source_table, row_id, operation, previous, …)`. The
|
|
333
|
+
`previous` column is why this is not merely a list of ids: without the row as it
|
|
334
|
+
was, the cell it is *leaving* cannot be located, and that cell would keep the
|
|
335
|
+
departed row in its totals forever.
|
|
336
|
+
4. **A worker** that claims a batch, works out which cells the changes could have
|
|
337
|
+
touched, and rebuilds them.
|
|
338
|
+
|
|
339
|
+
**Recomputing a cell is the primitive; it is not a fallback.** A recompute is one
|
|
340
|
+
aggregate query scoped to a cell and it is correct no matter what happened, so the
|
|
341
|
+
design is built on it rather than on increments. The rule underneath: recomputing a
|
|
342
|
+
cell that did not need it is harmless, while missing one that did is the only
|
|
343
|
+
unforgivable bug. When in doubt, Grain recomputes.
|
|
344
|
+
|
|
345
|
+
Claiming and applying happen in one transaction — the log rows are deleted and the
|
|
346
|
+
rollups rewritten together, so a crash rolls the deletions back and the work is
|
|
347
|
+
simply redone. Claiming uses `FOR UPDATE SKIP LOCKED`, so several workers can drain
|
|
348
|
+
one log without waiting on each other or repeating work.
|
|
349
|
+
|
|
350
|
+
### What the triggers cost
|
|
351
|
+
|
|
352
|
+
Every write to a watched table inserts a row into the change log. That is the real
|
|
353
|
+
price of this approach and it is worth knowing before you adopt it.
|
|
354
|
+
|
|
355
|
+
Grain narrows the `UPDATE` trigger to the columns that can actually move a row
|
|
356
|
+
between cells:
|
|
357
|
+
|
|
358
|
+
```sql
|
|
359
|
+
AFTER INSERT OR UPDATE OF placed_on, state, store_id OR DELETE ON orders
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
The asymmetry is deliberate. Related tables get an exact column list — at each hop
|
|
363
|
+
only the foreign key to the next one matters, or the dimension's own column at the
|
|
364
|
+
end — while **fact tables log every update**, because measures aggregate arbitrary
|
|
365
|
+
SQL and guessing which columns feed them risks missing an update and drifting in
|
|
366
|
+
silence. Precise where it can be, conservative where it cannot.
|
|
367
|
+
|
|
368
|
+
The column list is the union across every rollup that watches a table, so adding
|
|
369
|
+
a rollup never narrows a trigger another one depends on.
|
|
370
|
+
|
|
371
|
+
## Configuration
|
|
372
|
+
|
|
373
|
+
```ruby
|
|
374
|
+
# config/initializers/grain.rb
|
|
375
|
+
Grain.configure do |config|
|
|
376
|
+
config.change_log_table = "grain_change_log" # baked into the trigger function
|
|
377
|
+
config.batch_size = 1_000 # change log rows claimed per transaction
|
|
378
|
+
config.max_run_seconds = 30 # how long a drain may run before yielding
|
|
379
|
+
config.time_zone = "UTC" # the zone day buckets are cut in
|
|
380
|
+
config.logger = Rails.logger
|
|
381
|
+
end
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
Changing `change_log_table` after installing needs a new migration: the name is
|
|
385
|
+
written into the trigger function.
|
|
386
|
+
|
|
387
|
+
## Requirements
|
|
388
|
+
|
|
389
|
+
- Ruby 3.2 or newer
|
|
390
|
+
- Rails / ActiveRecord 7.1 or newer — composite primary keys landed there, and
|
|
391
|
+
rollup tables are keyed on one
|
|
392
|
+
- PostgreSQL 15 or newer. Earlier versions work only if no dimension resolves to a
|
|
393
|
+
nullable column: a null cannot sit in a primary key, so those rollups fall back
|
|
394
|
+
to a surrogate key plus a unique index with `NULLS NOT DISTINCT`, which is 15+.
|
|
395
|
+
|
|
396
|
+
## Limitations
|
|
397
|
+
|
|
398
|
+
Stated plainly, because finding these out later is worse than reading them now.
|
|
399
|
+
|
|
400
|
+
- **Postgres only.** The triggers, `jsonb_populate_record` and the upsert
|
|
401
|
+
semantics are all Postgres-specific.
|
|
402
|
+
- **Additive measures only.** `count`, `sum`, `min`, `max`. No distinct counts and
|
|
403
|
+
no percentiles: neither can be maintained without reading the rest of the cell's
|
|
404
|
+
source rows, which needs sketches (HyperLogLog, t-digest) rather than a column.
|
|
405
|
+
- **`belongs_to` chains only**, three hops deep. No `has_many`, no join tables.
|
|
406
|
+
- **Daily grain only.** No hourly buckets yet.
|
|
407
|
+
- **One fact per rollup.** No joins between facts, and no rollups built on rollups.
|
|
408
|
+
- **No deltas.** The worker recomputes affected cells rather than incrementing
|
|
409
|
+
them. Batching makes this fine in ordinary use — a thousand inserts landing in
|
|
410
|
+
ten cells cost ten recomputes — but a single enormous cell is recomputed in full
|
|
411
|
+
every time it is touched.
|
|
412
|
+
- **No job integration.** There is no ActiveJob class yet: run `rake grain:drain`
|
|
413
|
+
on a schedule or call `Grain::Worker.drain` from a job of your own.
|
|
414
|
+
- **`pause:` is a fixed wait**, not adaptive throttling on replication lag.
|
|
415
|
+
- **A rollup with a broken model reference is skipped with a warning** rather than
|
|
416
|
+
raising, so one bad rollup cannot stop the log from draining. Watch your logs.
|
|
417
|
+
|
|
418
|
+
## Development
|
|
419
|
+
|
|
420
|
+
```console
|
|
421
|
+
$ bin/setup
|
|
422
|
+
$ bundle exec rake # tests and rubocop
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
Integration tests need a PostgreSQL to talk to and skip themselves when there
|
|
426
|
+
isn't one, so the repository stays clonable without it:
|
|
427
|
+
|
|
428
|
+
```console
|
|
429
|
+
$ docker run -d --name grain-pg -e POSTGRES_PASSWORD=grain \
|
|
430
|
+
-e POSTGRES_DB=grain_test -p 5433:5432 postgres:18
|
|
431
|
+
$ bundle exec rake
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
Point it elsewhere with `GRAIN_TEST_DATABASE_URL`.
|
|
435
|
+
|
|
436
|
+
The SQL Grain generates is the product, so the integration tests run the
|
|
437
|
+
generators for real, load the files they write, and apply them to a live database.
|
|
438
|
+
Asserting on generated strings alone is false confidence: a string can be
|
|
439
|
+
syntactically perfect and semantically wrong, and more than one has been.
|
|
440
|
+
|
|
441
|
+
## Contributing
|
|
442
|
+
|
|
443
|
+
Bug reports and pull requests are welcome at https://github.com/grainrb/grain.
|
|
444
|
+
|
|
445
|
+
## License
|
|
446
|
+
|
|
447
|
+
MIT. See [LICENSE.txt](LICENSE.txt).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module Grain
|
|
7
|
+
module Generators
|
|
8
|
+
# Sets up the infrastructure every rollup shares: the change log table and the
|
|
9
|
+
# trigger function that watched tables call.
|
|
10
|
+
#
|
|
11
|
+
# Triggers themselves are not installed here. Which tables need watching
|
|
12
|
+
# depends on the rollups an application declares, so they are attached per
|
|
13
|
+
# rollup once those exist.
|
|
14
|
+
class InstallGenerator < Rails::Generators::Base
|
|
15
|
+
include ActiveRecord::Generators::Migration
|
|
16
|
+
|
|
17
|
+
source_root File.expand_path("templates", __dir__)
|
|
18
|
+
|
|
19
|
+
desc "Creates the Grain change log migration, an initializer and app/rollups."
|
|
20
|
+
|
|
21
|
+
def create_change_log_migration
|
|
22
|
+
migration_template "create_grain_change_log.rb.erb",
|
|
23
|
+
"db/migrate/create_grain_change_log.rb"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def create_initializer
|
|
27
|
+
template "initializer.rb.erb", "config/initializers/grain.rb"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def create_rollups_directory
|
|
31
|
+
create_file "app/rollups/.keep"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def report_next_steps
|
|
35
|
+
say ""
|
|
36
|
+
say "Grain installed. Next:", :green
|
|
37
|
+
say " 1. bin/rails db:migrate"
|
|
38
|
+
say " 2. Declare a rollup in app/rollups"
|
|
39
|
+
say " 3. bin/rails generate grain:rollup <name> (to build its table and triggers)"
|
|
40
|
+
say ""
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def migration_class_name
|
|
46
|
+
"CreateGrainChangeLog"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def migration_version
|
|
50
|
+
"[#{ActiveRecord::Migration.current_version}]"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def change_log_table
|
|
54
|
+
Grain::ChangeLog.table_name
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Migrations are snapshots: the SQL is written into the file rather than
|
|
58
|
+
# read from Grain at run time, so that upgrading the gem never changes what
|
|
59
|
+
# an already-applied migration says it did.
|
|
60
|
+
def indented(text, spaces)
|
|
61
|
+
prefix = " " * spaces
|
|
62
|
+
text.each_line.map { |line| line.strip.empty? ? line : "#{prefix}#{line}" }.join
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def change_log_table_definition
|
|
66
|
+
indented(Grain::ChangeLog.table_definition, 4).rstrip
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def change_log_function_sql
|
|
70
|
+
indented(Grain::ChangeLog.function_sql, 6).rstrip
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def change_log_drop_function_sql
|
|
74
|
+
Grain::ChangeLog.drop_function_sql
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Written by `rails generate grain:install`.
|
|
4
|
+
#
|
|
5
|
+
# Every table Grain watches gets an AFTER trigger that calls
|
|
6
|
+
# <%= Grain::ChangeLog::FUNCTION_NAME %>() and records what changed here. A worker
|
|
7
|
+
# reads this log forward by id, decides which rollups are affected, and prunes
|
|
8
|
+
# what it has processed.
|
|
9
|
+
class <%= migration_class_name %> < ActiveRecord::Migration<%= migration_version %>
|
|
10
|
+
def up
|
|
11
|
+
<%= change_log_table_definition %>
|
|
12
|
+
|
|
13
|
+
execute <<~SQL
|
|
14
|
+
<%= change_log_function_sql %>
|
|
15
|
+
SQL
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def down
|
|
19
|
+
execute "<%= change_log_drop_function_sql %>"
|
|
20
|
+
drop_table :<%= change_log_table %>
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Grain.configure do |config|
|
|
4
|
+
# Table the database triggers write change events into. Changing it after the
|
|
5
|
+
# install migration has run needs a new migration: the name is baked into the
|
|
6
|
+
# trigger function.
|
|
7
|
+
config.change_log_table = <%= change_log_table.inspect %>
|
|
8
|
+
|
|
9
|
+
# Rows per batch when draining the change log or backfilling. Small enough to
|
|
10
|
+
# keep transactions short, so a busy table is never blocked for long.
|
|
11
|
+
config.batch_size = 1_000
|
|
12
|
+
|
|
13
|
+
# Seconds a worker may run before yielding, so a large backlog cannot
|
|
14
|
+
# monopolise a queue slot.
|
|
15
|
+
config.max_run_seconds = 30
|
|
16
|
+
|
|
17
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
|
|
5
|
+
module Grain
|
|
6
|
+
module Generators
|
|
7
|
+
# Writes a rollup definition for you to fill in.
|
|
8
|
+
#
|
|
9
|
+
# Separate from grain:table on purpose: this runs once per rollup, while the
|
|
10
|
+
# migration that builds its table gets regenerated every time the definition
|
|
11
|
+
# gains a dimension or a measure.
|
|
12
|
+
class RollupGenerator < Rails::Generators::NamedBase
|
|
13
|
+
source_root File.expand_path("templates", __dir__)
|
|
14
|
+
|
|
15
|
+
desc "Writes app/rollups/NAME_rollup.rb for you to fill in."
|
|
16
|
+
|
|
17
|
+
def create_rollup_file
|
|
18
|
+
template "rollup.rb.erb", File.join("app/rollups", "#{file_name}_rollup.rb")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def report_next_steps
|
|
22
|
+
say ""
|
|
23
|
+
say "Fill in #{file_name}_rollup.rb, then:", :green
|
|
24
|
+
say " bin/rails generate grain:table #{file_name}"
|
|
25
|
+
say ""
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def rollup_class_name
|
|
31
|
+
"#{class_name.delete_suffix("Rollup")}Rollup"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def file_name
|
|
35
|
+
super.delete_suffix("_rollup")
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|