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
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class <%= rollup_class_name %> < Grain::Rollup
|
|
4
|
+
# The table the measures are read from and whose rows are counted. `where`
|
|
5
|
+
# narrows it, and may reach through a belongs_to.
|
|
6
|
+
#
|
|
7
|
+
# fact LineItem, where: { order: { state: "paid" } }
|
|
8
|
+
fact :TODO
|
|
9
|
+
|
|
10
|
+
# The column the rollup is partitioned by. Required: starting the key with the
|
|
11
|
+
# most selective column is what keeps reads and recomputes cheap.
|
|
12
|
+
#
|
|
13
|
+
# tenant :store_id, via: { order: :store_id }
|
|
14
|
+
tenant :TODO, via: :TODO
|
|
15
|
+
|
|
16
|
+
# Optional. Without it the rollup is a running total per dimension — a counter
|
|
17
|
+
# cache that can be verified instead of drifting.
|
|
18
|
+
#
|
|
19
|
+
# time :ordered_on, via: { order: :placed_at }, grain: :day
|
|
20
|
+
|
|
21
|
+
# Dimensions are resolved by following belongs_to associations upward from the
|
|
22
|
+
# fact, up to three hops. `via` can also name a column on the fact itself.
|
|
23
|
+
# Mark one immutable to promise it never changes after the row is created, and
|
|
24
|
+
# Grain will skip watching that table.
|
|
25
|
+
#
|
|
26
|
+
# dimension :product_id, via: :product_id
|
|
27
|
+
# dimension :category_id, via: { product: :category_id }
|
|
28
|
+
# dimension :currency, via: { order: :currency }, immutable: true
|
|
29
|
+
|
|
30
|
+
# count needs no type. Every other aggregate runs over SQL whose type cannot be
|
|
31
|
+
# inferred, and guessing would mean silently rounding your own numbers.
|
|
32
|
+
#
|
|
33
|
+
# measure :line_count, count: true
|
|
34
|
+
# measure :revenue_cents, sum: "quantity * unit_price_cents", type: :bigint
|
|
35
|
+
|
|
36
|
+
# Ratios are stored as their two parts and divided on read, so a rate stays
|
|
37
|
+
# correct at every grain rather than being frozen at the one it was computed for.
|
|
38
|
+
#
|
|
39
|
+
# ratio :average_unit_price, of: :revenue_cents, over: :units
|
|
40
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module Grain
|
|
7
|
+
module Generators
|
|
8
|
+
# Builds the migration for a rollup that already exists: its table, and the
|
|
9
|
+
# triggers on every table its definition depends on.
|
|
10
|
+
#
|
|
11
|
+
# Run it again whenever the definition changes. The rollup's shape is derived
|
|
12
|
+
# from the class, so the generated migration always matches what the code
|
|
13
|
+
# currently declares.
|
|
14
|
+
class TableGenerator < Rails::Generators::NamedBase
|
|
15
|
+
include ActiveRecord::Generators::Migration
|
|
16
|
+
|
|
17
|
+
source_root File.expand_path("templates", __dir__)
|
|
18
|
+
|
|
19
|
+
desc "Generates the migration that creates NAME's rollup table and its triggers."
|
|
20
|
+
|
|
21
|
+
def create_table_migration
|
|
22
|
+
migration_template "create_rollup_table.rb.erb", "db/migrate/#{rollup_migration_basename}.rb"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def report_trigger_tables
|
|
26
|
+
say ""
|
|
27
|
+
say "Triggers will be attached to:", :green
|
|
28
|
+
triggers.specs.each do |spec|
|
|
29
|
+
scope = spec.narrowed? ? "updates of #{spec.update_columns.join(", ")}" : "all updates"
|
|
30
|
+
say " #{spec.table} (#{scope})"
|
|
31
|
+
end
|
|
32
|
+
say ""
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def rollup
|
|
38
|
+
@rollup ||= resolve_rollup
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Thor prints the message and stops, which is the right behaviour for a
|
|
42
|
+
# command line; the rule itself lives in RollupLookup so it can be tested.
|
|
43
|
+
def resolve_rollup
|
|
44
|
+
Grain::RollupLookup.find!(name)
|
|
45
|
+
rescue Grain::RollupNotFoundError => e
|
|
46
|
+
raise Thor::Error, e.message
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def definition
|
|
50
|
+
@definition ||= rollup.validate!
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def migration
|
|
54
|
+
@migration ||= Grain::Migration.new(definition)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Every rollup in the application, not just this one. The trigger on a
|
|
58
|
+
# table is shared, so narrowing it to what this rollup happens to need
|
|
59
|
+
# would silently break any other rollup that needs more.
|
|
60
|
+
def triggers
|
|
61
|
+
@triggers ||= Grain::Triggers.new(all_definitions)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def all_definitions
|
|
65
|
+
(Grain::Registry.all + [rollup]).uniq.map(&:definition)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Deliberately not called migration_file_name or migration_class_name:
|
|
69
|
+
# Rails' own migration machinery calls methods by those names on the
|
|
70
|
+
# generator, and shadowing them breaks it from the inside.
|
|
71
|
+
def rollup_migration_basename
|
|
72
|
+
"create_#{migration.table_name}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def rollup_migration_class_name
|
|
76
|
+
rollup_migration_basename.camelize
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def migration_version
|
|
80
|
+
"[#{ActiveRecord::Migration.current_version}]"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def indented(text, spaces)
|
|
84
|
+
prefix = " " * spaces
|
|
85
|
+
text.each_line.map { |line| line.strip.empty? ? line : "#{prefix}#{line}" }.join
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def table_body
|
|
89
|
+
indented(migration.up, 4).rstrip
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def executed_statements(statements, spaces)
|
|
93
|
+
statements.map do |statement|
|
|
94
|
+
"#{" " * spaces}execute <<~SQL\n#{indented(statement, spaces + 2)}\n#{" " * spaces}SQL"
|
|
95
|
+
end.join("\n\n")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def trigger_up
|
|
99
|
+
executed_statements(triggers.up_statements, 4)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def trigger_down
|
|
103
|
+
executed_statements(triggers.down_statements, 4)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def drop_table_statement
|
|
107
|
+
migration.down
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Written by `rails generate grain:table <%= file_name %>` from
|
|
4
|
+
# <%= rollup.name %>.
|
|
5
|
+
#
|
|
6
|
+
# Regenerate this whenever that definition changes: the table's shape and the
|
|
7
|
+
# tables it watches are both derived from it.
|
|
8
|
+
class <%= rollup_migration_class_name %> < ActiveRecord::Migration<%= migration_version %>
|
|
9
|
+
def up
|
|
10
|
+
<%= table_body %>
|
|
11
|
+
|
|
12
|
+
<%= trigger_up %>
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def down
|
|
16
|
+
<%= trigger_down %>
|
|
17
|
+
|
|
18
|
+
<%= drop_table_statement %>
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Grain
|
|
4
|
+
# Populates a rollup from data that already exists.
|
|
5
|
+
#
|
|
6
|
+
# A new rollup starts empty: its triggers only see what happens next. The
|
|
7
|
+
# backfill is what makes it true about the past.
|
|
8
|
+
#
|
|
9
|
+
# The work is sliced rather than batched by row. Rows belonging to one cell are
|
|
10
|
+
# scattered through the fact table, so a batch of rows would have to add to
|
|
11
|
+
# cells already written, which is the delta problem again with none of its
|
|
12
|
+
# safeguards. A slice — one day, or one tenant — is instead rebuilt whole with
|
|
13
|
+
# the same recompute the worker uses: idempotent, never leaving a cell showing a
|
|
14
|
+
# partial total, and safe to run while the worker is running.
|
|
15
|
+
#
|
|
16
|
+
# Resuming after an interruption is manual and deliberate: slices are processed
|
|
17
|
+
# in order, so passing `from:` the last one reported picks up where it stopped.
|
|
18
|
+
# Re-running an already-done slice is harmless either way.
|
|
19
|
+
class Backfill
|
|
20
|
+
attr_reader :rollup, :definition
|
|
21
|
+
|
|
22
|
+
def initialize(rollup, from: nil, pause: 0)
|
|
23
|
+
@rollup = rollup
|
|
24
|
+
@definition = rollup.definition.validate!
|
|
25
|
+
@projection = Projection.new(@definition)
|
|
26
|
+
@from = from
|
|
27
|
+
@pause = pause
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Yields each slice value as it completes, so a caller can report progress or
|
|
31
|
+
# record where to resume from. Returns the number of slices rebuilt.
|
|
32
|
+
def call
|
|
33
|
+
recompute = Recompute.new(definition)
|
|
34
|
+
slices.each_with_index do |value, index|
|
|
35
|
+
recompute.call_slice(slice_dimension, value)
|
|
36
|
+
yield(value, index + 1, slices.length) if block_given?
|
|
37
|
+
sleep(@pause) if @pause.positive?
|
|
38
|
+
end
|
|
39
|
+
slices.length
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The dimension the work is cut along: the time bucket when there is one,
|
|
43
|
+
# since a day is a naturally bounded unit of work, and the tenant otherwise.
|
|
44
|
+
def slice_dimension
|
|
45
|
+
definition.temporal? ? definition.time : definition.tenant
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Distinct slice values that actually have data, in order.
|
|
49
|
+
#
|
|
50
|
+
# This is the expensive part of a backfill: it reads the fact table to find
|
|
51
|
+
# them. It is one pass and far cheaper than the full aggregate, and using the
|
|
52
|
+
# distinct values rather than a min-to-max range skips every gap.
|
|
53
|
+
def slices
|
|
54
|
+
@slices ||= load_slices
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def load_slices
|
|
60
|
+
expression = @projection.dimension_expression(slice_dimension)
|
|
61
|
+
sql = +"SELECT DISTINCT #{expression} AS slice FROM #{@projection.from_and_joins.join(" ")}"
|
|
62
|
+
conditions = @projection.filter_conditions
|
|
63
|
+
conditions << "#{expression} >= #{quote(@from)}" unless @from.nil?
|
|
64
|
+
sql << " WHERE #{conditions.join(" AND ")}" if conditions.any?
|
|
65
|
+
sql << " ORDER BY slice"
|
|
66
|
+
connection.select_values(sql)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def quote(value)
|
|
70
|
+
connection.quote(value)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def connection
|
|
74
|
+
ActiveRecord::Base.connection
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
data/lib/grain/cells.rb
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Grain
|
|
4
|
+
# Finds the cells a change could have touched.
|
|
5
|
+
#
|
|
6
|
+
# The rule this is built on: recomputing a cell that did not need it is
|
|
7
|
+
# harmless, while missing one that did is the only unforgivable bug. So the
|
|
8
|
+
# search is deliberately generous — the fact's filter is applied when looking
|
|
9
|
+
# for where rows are now, and left off when looking for where they used to be.
|
|
10
|
+
class Cells
|
|
11
|
+
attr_reader :projection
|
|
12
|
+
|
|
13
|
+
def initialize(definition)
|
|
14
|
+
@definition = definition
|
|
15
|
+
@projection = Projection.new(definition)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Cells the given fact rows belong to as things stand.
|
|
19
|
+
def live_for_facts(row_ids)
|
|
20
|
+
return [] if row_ids.empty?
|
|
21
|
+
|
|
22
|
+
select(conditions: [id_in(Projection::FACT, row_ids)] + projection.filter_conditions)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# The cell a fact row sat in before it changed, rebuilt from the log. Without
|
|
26
|
+
# this the row's contribution would sit in its old cell forever, because
|
|
27
|
+
# nothing points there any more.
|
|
28
|
+
def previous_for_fact(previous_json)
|
|
29
|
+
select(substitutions: { [] => previous_json })
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Cells reachable through a row of a watched table, as things stand.
|
|
33
|
+
def live_through(hops, row_ids)
|
|
34
|
+
return [] if row_ids.empty?
|
|
35
|
+
|
|
36
|
+
select(conditions: [id_in(projection.alias_for(hops), row_ids)] + projection.filter_conditions)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# ...and as they were, with that table's row rebuilt from the log.
|
|
40
|
+
def previous_through(hops, previous_json)
|
|
41
|
+
select(substitutions: { hops => previous_json })
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def select(conditions: [], substitutions: {})
|
|
47
|
+
sql = +"SELECT DISTINCT #{selection} FROM #{projection.from_and_joins(substitutions).join(" ")}"
|
|
48
|
+
sql << " WHERE #{conditions.join(" AND ")}" if conditions.any?
|
|
49
|
+
connection.select_all(sql).to_a.map(&:symbolize_keys)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def selection
|
|
53
|
+
projection.key_columns.zip(projection.dimension_expressions)
|
|
54
|
+
.map { |name, expression| "#{expression} AS #{name}" }
|
|
55
|
+
.join(", ")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def id_in(table_alias, row_ids)
|
|
59
|
+
"#{table_alias}.id IN (#{row_ids.map { |id| connection.quote(id) }.join(", ")})"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def connection
|
|
63
|
+
ActiveRecord::Base.connection
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Grain
|
|
4
|
+
# The single table every trigger writes into, the trigger function they all
|
|
5
|
+
# share, and the vocabulary of what a trigger can record.
|
|
6
|
+
#
|
|
7
|
+
# One trigger per source table, never one per rollup: several rollups can read
|
|
8
|
+
# the same fact table, and triggers must not multiply with them. A trigger
|
|
9
|
+
# records that a row changed and nothing more. Deciding which rollups care, and
|
|
10
|
+
# which of their cells are affected, is the worker's job.
|
|
11
|
+
module ChangeLog
|
|
12
|
+
OPERATIONS = %i[insert update delete].freeze
|
|
13
|
+
|
|
14
|
+
FUNCTION_NAME = "grain_record_change"
|
|
15
|
+
|
|
16
|
+
COLUMNS = {
|
|
17
|
+
id: :bigserial,
|
|
18
|
+
source_table: :text,
|
|
19
|
+
# Text rather than bigint so that Grain works on integer, UUID and string
|
|
20
|
+
# primary keys alike. The worker casts it back when it reads the source, so
|
|
21
|
+
# the fact table's own index is still used.
|
|
22
|
+
row_id: :text,
|
|
23
|
+
operation: :text,
|
|
24
|
+
# The row as it was before the change, recorded for updates and deletes.
|
|
25
|
+
#
|
|
26
|
+
# Without it, the cell a row is leaving cannot be located: once the row
|
|
27
|
+
# carries its new values, nothing points back at where it used to be
|
|
28
|
+
# counted, and that cell would keep the departed row in its totals
|
|
29
|
+
# forever. This is why the change log is not merely a list of ids.
|
|
30
|
+
previous: :jsonb,
|
|
31
|
+
created_at: :timestamptz
|
|
32
|
+
}.freeze
|
|
33
|
+
|
|
34
|
+
class << self
|
|
35
|
+
def table_name
|
|
36
|
+
Grain.config.change_log_table
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def operation!(name)
|
|
40
|
+
operation = name.to_s.downcase.to_sym
|
|
41
|
+
return operation if OPERATIONS.include?(operation)
|
|
42
|
+
|
|
43
|
+
raise Error, "unknown change log operation #{name.inspect}, expected #{OPERATIONS.inspect}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Updates and deletes need the previous row to locate the cell being left.
|
|
47
|
+
# An insert leaves nothing behind, so it needs none.
|
|
48
|
+
def previous_required?(operation)
|
|
49
|
+
operation!(operation) != :insert
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# No secondary indexes: the worker reads forward by id and prunes by id, so
|
|
53
|
+
# the primary key already covers both. The log is drained continuously and
|
|
54
|
+
# is never meant to grow.
|
|
55
|
+
def table_definition
|
|
56
|
+
<<~RUBY
|
|
57
|
+
create_table :#{table_name} do |t|
|
|
58
|
+
t.text :source_table, null: false
|
|
59
|
+
t.text :row_id, null: false
|
|
60
|
+
t.text :operation, null: false
|
|
61
|
+
t.jsonb :previous
|
|
62
|
+
t.datetime :created_at, null: false
|
|
63
|
+
end
|
|
64
|
+
RUBY
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# One function for every watched table. TG_TABLE_NAME tells the worker which
|
|
68
|
+
# table a row came from, so nothing here has to be generated per table.
|
|
69
|
+
#
|
|
70
|
+
# clock_timestamp() rather than now(): now() returns the transaction's start
|
|
71
|
+
# time, which would stamp every row written in one transaction identically.
|
|
72
|
+
def function_sql
|
|
73
|
+
<<~SQL
|
|
74
|
+
CREATE OR REPLACE FUNCTION #{FUNCTION_NAME}() RETURNS trigger
|
|
75
|
+
LANGUAGE plpgsql AS $$
|
|
76
|
+
BEGIN
|
|
77
|
+
IF TG_OP = 'INSERT' THEN
|
|
78
|
+
INSERT INTO #{table_name} (source_table, row_id, operation, previous, created_at)
|
|
79
|
+
VALUES (TG_TABLE_NAME, NEW.id::text, 'insert', NULL, clock_timestamp());
|
|
80
|
+
RETURN NEW;
|
|
81
|
+
ELSIF TG_OP = 'UPDATE' THEN
|
|
82
|
+
INSERT INTO #{table_name} (source_table, row_id, operation, previous, created_at)
|
|
83
|
+
VALUES (TG_TABLE_NAME, NEW.id::text, 'update', to_jsonb(OLD), clock_timestamp());
|
|
84
|
+
RETURN NEW;
|
|
85
|
+
ELSE
|
|
86
|
+
INSERT INTO #{table_name} (source_table, row_id, operation, previous, created_at)
|
|
87
|
+
VALUES (TG_TABLE_NAME, OLD.id::text, 'delete', to_jsonb(OLD), clock_timestamp());
|
|
88
|
+
RETURN OLD;
|
|
89
|
+
END IF;
|
|
90
|
+
END;
|
|
91
|
+
$$;
|
|
92
|
+
SQL
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def drop_function_sql
|
|
96
|
+
"DROP FUNCTION IF EXISTS #{FUNCTION_NAME}();"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Grain
|
|
4
|
+
# Application-wide settings. Defaults are chosen to be safe on a busy
|
|
5
|
+
# production database rather than fast on an idle one.
|
|
6
|
+
class Configuration
|
|
7
|
+
# Table that database triggers write change events into.
|
|
8
|
+
attr_accessor :change_log_table
|
|
9
|
+
|
|
10
|
+
# Rows per batch when backfilling or draining the change log. Small enough
|
|
11
|
+
# to keep transactions short and avoid long lock holds.
|
|
12
|
+
attr_accessor :batch_size
|
|
13
|
+
|
|
14
|
+
# Seconds a delta worker may run before yielding, so a large backlog cannot
|
|
15
|
+
# monopolise a job queue slot.
|
|
16
|
+
attr_accessor :max_run_seconds
|
|
17
|
+
|
|
18
|
+
# Time zone the day buckets are cut in. A timestamp has to be resolved to a
|
|
19
|
+
# calendar day in some zone, and leaving it to the database session would make
|
|
20
|
+
# the same row land in different buckets depending on who ran the query.
|
|
21
|
+
attr_accessor :time_zone
|
|
22
|
+
|
|
23
|
+
# Where Grain writes its own diagnostics.
|
|
24
|
+
attr_accessor :logger
|
|
25
|
+
|
|
26
|
+
def initialize
|
|
27
|
+
@change_log_table = "grain_change_log"
|
|
28
|
+
@batch_size = 1_000
|
|
29
|
+
@max_run_seconds = 30
|
|
30
|
+
@time_zone = "UTC"
|
|
31
|
+
@logger = nil
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Grain
|
|
4
|
+
# Everything a Rollup subclass declares, plus what can be derived from it: the
|
|
5
|
+
# rollup table's name and key order, and which tables have to be watched to
|
|
6
|
+
# keep the aggregate correct.
|
|
7
|
+
class Definition
|
|
8
|
+
TABLE_PREFIX = "grain_"
|
|
9
|
+
|
|
10
|
+
attr_reader :owner, :fact, :dimensions, :measures, :ratios
|
|
11
|
+
|
|
12
|
+
def initialize(owner)
|
|
13
|
+
@owner = owner
|
|
14
|
+
@fact = nil
|
|
15
|
+
@dimensions = []
|
|
16
|
+
@measures = []
|
|
17
|
+
@ratios = []
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def declare_fact(model, where: nil)
|
|
21
|
+
raise InvalidDefinitionError, "#{owner} already declares a fact" if fact
|
|
22
|
+
|
|
23
|
+
@fact = Fact.new(model, where: where)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def add_dimension(name, via:, role: :dimension, grain: nil, immutable: false)
|
|
27
|
+
dimension = Dimension.new(name: name, via: via, role: role, grain: grain, immutable: immutable)
|
|
28
|
+
reject_duplicate_name!(dimension.name)
|
|
29
|
+
reject_duplicate_role!(dimension)
|
|
30
|
+
@dimensions << dimension
|
|
31
|
+
dimension
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def add_measure(name, options)
|
|
35
|
+
measure = Measure.from_options(name, options)
|
|
36
|
+
reject_duplicate_name!(measure.name)
|
|
37
|
+
@measures << measure
|
|
38
|
+
measure
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def add_ratio(name, numerator:, denominator:)
|
|
42
|
+
ratio = Ratio.new(name: name, numerator: numerator, denominator: denominator)
|
|
43
|
+
reject_duplicate_name!(ratio.name)
|
|
44
|
+
@ratios << ratio
|
|
45
|
+
ratio
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def tenant
|
|
49
|
+
dimensions.find(&:tenant?)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def time
|
|
53
|
+
dimensions.find(&:time?)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# False for a rollup that buckets by nothing but its dimensions — a counter
|
|
57
|
+
# cache. Reads on one cannot take a date range, and it has no late-arriving
|
|
58
|
+
# data to worry about.
|
|
59
|
+
def temporal?
|
|
60
|
+
!time.nil?
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def plain_dimensions
|
|
64
|
+
dimensions.reject { |dimension| dimension.tenant? || dimension.time? }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# The rollup table's primary key, in a fixed order so that migrations,
|
|
68
|
+
# delta updates and reads all agree: tenant, then time bucket, then the
|
|
69
|
+
# plain dimensions in declaration order.
|
|
70
|
+
def key_dimensions
|
|
71
|
+
[tenant, time, *plain_dimensions].compact
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def table_name
|
|
75
|
+
raise InvalidDefinitionError, "a rollup needs a name to derive its table from" if anonymous?
|
|
76
|
+
|
|
77
|
+
"#{TABLE_PREFIX}#{owner.name.underscore.pluralize}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Paths whose tables can move fact rows between cells.
|
|
81
|
+
def watched_paths
|
|
82
|
+
dimensions.select(&:watched?).map(&:path).uniq
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Every association hop along a watched path, not only the first.
|
|
86
|
+
#
|
|
87
|
+
# A change anywhere along the path moves fact rows between cells: if a
|
|
88
|
+
# dimension resolves as order then store then currency, changing that store's
|
|
89
|
+
# currency moves every line item on every order pointing at it. Watching only
|
|
90
|
+
# the root would let that drift silently, which is the worst failure this
|
|
91
|
+
# gem can have.
|
|
92
|
+
def watched_associations
|
|
93
|
+
watched_paths.flat_map(&:hops).uniq
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Associations the fact's filter reaches through. A change there adds or
|
|
97
|
+
# removes fact rows entirely, which no delta can express.
|
|
98
|
+
def filter_associations
|
|
99
|
+
return [] unless fact
|
|
100
|
+
|
|
101
|
+
fact.filter_associations
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# True when a change somewhere other than the fact table can add or remove
|
|
105
|
+
# fact rows entirely, which no delta can express.
|
|
106
|
+
def invalidated_by_filter?
|
|
107
|
+
fact ? fact.filtered_through_association? : false
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def measure_names
|
|
111
|
+
measures.map(&:name)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def validate!
|
|
115
|
+
DefinitionValidator.new(self).validate!
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
def anonymous?
|
|
121
|
+
owner.name.nil? || owner.name.empty?
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def declared_names
|
|
125
|
+
dimensions.map(&:name) + measure_names + ratios.map(&:name)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def reject_duplicate_name!(name)
|
|
129
|
+
return unless declared_names.include?(name)
|
|
130
|
+
|
|
131
|
+
raise InvalidDefinitionError, "#{owner} already declares #{name.inspect}"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def reject_duplicate_role!(dimension)
|
|
135
|
+
return unless (dimension.tenant? && tenant) || (dimension.time? && time)
|
|
136
|
+
|
|
137
|
+
raise InvalidDefinitionError, "#{owner} already declares a #{dimension.role}"
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Grain
|
|
4
|
+
# Judges whether a definition is complete and internally consistent.
|
|
5
|
+
#
|
|
6
|
+
# Kept apart from Definition so that accumulating declarations and deciding
|
|
7
|
+
# whether they add up stay separate concerns: the DSL builds, this rules.
|
|
8
|
+
class DefinitionValidator
|
|
9
|
+
# Parts a rollup cannot do without.
|
|
10
|
+
#
|
|
11
|
+
# A time dimension is deliberately absent: a rollup without one is a counter
|
|
12
|
+
# cache that cannot drift, which is a use case in its own right. A tenant is
|
|
13
|
+
# required, because starting the key with the most selective column is what
|
|
14
|
+
# makes reads and scoped recomputes cheap.
|
|
15
|
+
REQUIRED_PARTS = {
|
|
16
|
+
fact: "declares no fact",
|
|
17
|
+
tenant: "declares no tenant"
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
attr_reader :definition
|
|
21
|
+
|
|
22
|
+
def initialize(definition)
|
|
23
|
+
@definition = definition
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def validate!
|
|
27
|
+
validate_required_parts!
|
|
28
|
+
validate_ratios!
|
|
29
|
+
definition
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def owner
|
|
35
|
+
definition.owner
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def validate_required_parts!
|
|
39
|
+
REQUIRED_PARTS.each do |reader, complaint|
|
|
40
|
+
raise InvalidDefinitionError, "#{owner} #{complaint}" if definition.public_send(reader).nil?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
raise InvalidDefinitionError, "#{owner} declares no measures" if definition.measures.empty?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def validate_ratios!
|
|
47
|
+
definition.ratios.each { |ratio| validate_ratio!(ratio) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def validate_ratio!(ratio)
|
|
51
|
+
[ratio.numerator, ratio.denominator].each do |part|
|
|
52
|
+
next if definition.measure_names.include?(part)
|
|
53
|
+
|
|
54
|
+
raise InvalidDefinitionError, "ratio #{ratio.name} refers to unknown measure #{part.inspect}"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|