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.
Files changed (49) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +27 -0
  3. data/CODE_OF_CONDUCT.md +10 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +447 -0
  6. data/Rakefile +12 -0
  7. data/lib/generators/grain/install/install_generator.rb +78 -0
  8. data/lib/generators/grain/install/templates/create_grain_change_log.rb.erb +22 -0
  9. data/lib/generators/grain/install/templates/initializer.rb.erb +17 -0
  10. data/lib/generators/grain/rollup/rollup_generator.rb +39 -0
  11. data/lib/generators/grain/rollup/templates/rollup.rb.erb +40 -0
  12. data/lib/generators/grain/table/table_generator.rb +111 -0
  13. data/lib/generators/grain/table/templates/create_rollup_table.rb.erb +20 -0
  14. data/lib/grain/backfill.rb +77 -0
  15. data/lib/grain/cells.rb +66 -0
  16. data/lib/grain/change_log.rb +100 -0
  17. data/lib/grain/configuration.rb +34 -0
  18. data/lib/grain/definition.rb +140 -0
  19. data/lib/grain/definition_validator.rb +58 -0
  20. data/lib/grain/dimension.rb +62 -0
  21. data/lib/grain/discrepancy.rb +48 -0
  22. data/lib/grain/errors.rb +25 -0
  23. data/lib/grain/fact.rb +46 -0
  24. data/lib/grain/join_graph.rb +90 -0
  25. data/lib/grain/measure.rb +100 -0
  26. data/lib/grain/migration.rb +93 -0
  27. data/lib/grain/path.rb +93 -0
  28. data/lib/grain/projection.rb +116 -0
  29. data/lib/grain/query.rb +147 -0
  30. data/lib/grain/query_sql.rb +100 -0
  31. data/lib/grain/railtie.rb +21 -0
  32. data/lib/grain/ratio.rb +28 -0
  33. data/lib/grain/recompute.rb +101 -0
  34. data/lib/grain/registry.rb +84 -0
  35. data/lib/grain/rollup.rb +90 -0
  36. data/lib/grain/rollup_lookup.rb +41 -0
  37. data/lib/grain/schema.rb +63 -0
  38. data/lib/grain/triggers.rb +99 -0
  39. data/lib/grain/type_resolver.rb +83 -0
  40. data/lib/grain/verification.rb +73 -0
  41. data/lib/grain/verification_query.rb +134 -0
  42. data/lib/grain/verification_report.rb +48 -0
  43. data/lib/grain/version.rb +5 -0
  44. data/lib/grain/watched_columns.rb +70 -0
  45. data/lib/grain/worker.rb +109 -0
  46. data/lib/grain.rb +60 -0
  47. data/lib/tasks/grain.rake +33 -0
  48. data/sig/grain.rbs +4 -0
  49. 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`.
@@ -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,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "minitest/test_task"
5
+
6
+ Minitest::TestTask.create
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[test rubocop]
@@ -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