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,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Grain
|
|
4
|
+
# Works out which tables need a trigger, which columns of each are worth
|
|
5
|
+
# reacting to, and renders the SQL that attaches them.
|
|
6
|
+
#
|
|
7
|
+
# Built from every rollup that touches a table, not from one at a time. The
|
|
8
|
+
# trigger name is per table because the function it calls is shared, so a
|
|
9
|
+
# migration that considered only its own rollup would narrow a trigger another
|
|
10
|
+
# rollup depends on and leave that one drifting in silence. The column list is
|
|
11
|
+
# therefore the union across all of them.
|
|
12
|
+
class Triggers
|
|
13
|
+
# `update_columns` narrows the UPDATE trigger to the columns that can move a
|
|
14
|
+
# row between cells; nil means every update has to be logged.
|
|
15
|
+
Spec = Struct.new(:table, :update_columns, keyword_init: true) do
|
|
16
|
+
def trigger_name
|
|
17
|
+
"grain_#{table}_changed"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def narrowed?
|
|
21
|
+
!update_columns.nil?
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
attr_reader :definitions
|
|
26
|
+
|
|
27
|
+
def initialize(definitions)
|
|
28
|
+
@definitions = Array(definitions).map(&:validate!).uniq
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Fact tables first, then the tables reached through them.
|
|
32
|
+
def specs
|
|
33
|
+
fact_specs + related_specs
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# One statement per entry, so a migration can execute them individually
|
|
37
|
+
# instead of relying on the adapter accepting several at once.
|
|
38
|
+
def up_statements
|
|
39
|
+
specs.flat_map { |spec| [drop_sql(spec), create_sql(spec)] }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def down_statements
|
|
43
|
+
specs.map { |spec| drop_sql(spec) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def up
|
|
47
|
+
up_statements.join("\n")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def down
|
|
51
|
+
down_statements.join("\n")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# Measures aggregate arbitrary SQL, so which of a fact table's columns feed
|
|
57
|
+
# them cannot be known. Narrowing here risks missing an update and letting a
|
|
58
|
+
# rollup drift, so every update on a fact table is logged. Being a fact for
|
|
59
|
+
# any one rollup is enough to disqualify the table from narrowing.
|
|
60
|
+
def fact_specs
|
|
61
|
+
fact_tables.map { |table| Spec.new(table: table, update_columns: nil) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def fact_tables
|
|
65
|
+
definitions.map { |definition| TypeResolver.new(definition).fact_table }.uniq
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def related_specs
|
|
69
|
+
union_of_related_columns.reject { |table, _| fact_tables.include?(table) }
|
|
70
|
+
.map { |table, columns| Spec.new(table: table, update_columns: columns.sort) }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def union_of_related_columns
|
|
74
|
+
definitions.each_with_object({}) do |definition, union|
|
|
75
|
+
WatchedColumns.new(definition).to_h.each do |table, columns|
|
|
76
|
+
union[table] = ((union[table] || []) + columns).uniq
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def create_sql(spec)
|
|
82
|
+
<<~SQL.strip
|
|
83
|
+
CREATE TRIGGER #{spec.trigger_name}
|
|
84
|
+
AFTER INSERT OR #{update_clause(spec)} OR DELETE ON #{spec.table}
|
|
85
|
+
FOR EACH ROW EXECUTE FUNCTION #{ChangeLog::FUNCTION_NAME}();
|
|
86
|
+
SQL
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def update_clause(spec)
|
|
90
|
+
return "UPDATE" unless spec.narrowed?
|
|
91
|
+
|
|
92
|
+
"UPDATE OF #{spec.update_columns.join(", ")}"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def drop_sql(spec)
|
|
96
|
+
"DROP TRIGGER IF EXISTS #{spec.trigger_name} ON #{spec.table};"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Grain
|
|
4
|
+
# Works out the column type of every column of a rollup table by walking each
|
|
5
|
+
# dimension's declared path to the model that owns the source column.
|
|
6
|
+
#
|
|
7
|
+
# This is the one part of the schema layer that needs the application's models,
|
|
8
|
+
# which is why it is separate from Schema: the shape of a rollup can be reasoned
|
|
9
|
+
# about without a database, its types cannot.
|
|
10
|
+
class TypeResolver
|
|
11
|
+
# A time dimension does not store the source timestamp, it stores the bucket
|
|
12
|
+
# the row falls into, so its type comes from the grain rather than the source.
|
|
13
|
+
BUCKET_TYPES = { day: :date }.freeze
|
|
14
|
+
|
|
15
|
+
attr_reader :definition
|
|
16
|
+
|
|
17
|
+
def initialize(definition)
|
|
18
|
+
@definition = definition
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def dimension_type(dimension)
|
|
22
|
+
return BUCKET_TYPES.fetch(dimension.grain) if dimension.time?
|
|
23
|
+
|
|
24
|
+
source_column(dimension).type
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The type of the column a dimension is read from, before any bucketing. A
|
|
28
|
+
# time dimension needs this to know whether the source is already a calendar
|
|
29
|
+
# day or a timestamp that has to be resolved to one.
|
|
30
|
+
def source_type(dimension)
|
|
31
|
+
source_column(dimension).type
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Whether the source column can be null, which decides whether the rollup can
|
|
35
|
+
# use a plain composite primary key: Postgres will not accept a null in one.
|
|
36
|
+
def dimension_nullable?(dimension)
|
|
37
|
+
return false if dimension.time?
|
|
38
|
+
|
|
39
|
+
source_column(dimension).null
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def measure_type(measure)
|
|
43
|
+
measure.type
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def fact_table
|
|
47
|
+
definition.fact.model.table_name
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def nullable_dimensions
|
|
51
|
+
definition.key_dimensions.select { |dimension| dimension_nullable?(dimension) }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The validated belongs_to reflection for one hop. Public because working out
|
|
55
|
+
# which columns to watch needs the same walk and the same complaints.
|
|
56
|
+
def reflection!(model, hop, context)
|
|
57
|
+
reflection = model.reflect_on_association(hop)
|
|
58
|
+
raise InvalidDefinitionError, "#{model} has no association #{hop.inspect} (via #{context})" if reflection.nil?
|
|
59
|
+
raise InvalidDefinitionError, one_value_per_dimension(model, hop, context) unless reflection.belongs_to?
|
|
60
|
+
|
|
61
|
+
reflection
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def one_value_per_dimension(model, hop, context)
|
|
67
|
+
"#{model}##{hop} is not a belongs_to (via #{context}). A fact row must resolve to one " \
|
|
68
|
+
"value per dimension, or it would be counted in several cells at once."
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def source_column(dimension)
|
|
72
|
+
model = walk(definition.fact.model, dimension.path)
|
|
73
|
+
model.columns_hash.fetch(dimension.path.column.to_s) do
|
|
74
|
+
raise InvalidDefinitionError,
|
|
75
|
+
"#{dimension.name} resolves to #{model}##{dimension.path.column}, which does not exist"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def walk(model, path)
|
|
80
|
+
path.hops.reduce(model) { |current, hop| reflection!(current, hop, path).klass }
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Grain
|
|
4
|
+
# Recomputes a rollup from its source and reports every cell that disagrees.
|
|
5
|
+
#
|
|
6
|
+
# This is the point of the whole gem, not a diagnostic bolted on afterwards.
|
|
7
|
+
# Nobody puts an aggregation layer in front of numbers that matter without a way
|
|
8
|
+
# to prove it still tells the truth, so the obstacle Grain has to clear is never
|
|
9
|
+
# speed — it is doubt.
|
|
10
|
+
#
|
|
11
|
+
# A full verification is an aggregate scan of the source, which is a maintenance
|
|
12
|
+
# operation rather than something to run per request. Scope it by tenant or by
|
|
13
|
+
# date range on a large rollup.
|
|
14
|
+
class Verification
|
|
15
|
+
attr_reader :rollup, :definition
|
|
16
|
+
|
|
17
|
+
def initialize(rollup, tenant: nil, between: nil)
|
|
18
|
+
@rollup = rollup
|
|
19
|
+
@definition = rollup.definition.validate!
|
|
20
|
+
@scope = { tenant: tenant, between: between }
|
|
21
|
+
validate_scope!
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def call(repair: false)
|
|
25
|
+
report = VerificationReport.new(rollup: rollup, discrepancies: discrepancies)
|
|
26
|
+
return report unless repair && !report.clean?
|
|
27
|
+
|
|
28
|
+
report.with_repaired(Recompute.new(definition).call(report.discrepancies.map(&:cell)))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def discrepancies
|
|
32
|
+
connection.select_all(query.to_s).to_a.map { |row| build_discrepancy(row.symbolize_keys) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def query
|
|
36
|
+
VerificationQuery.new(definition, **@scope)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def key_columns
|
|
42
|
+
definition.key_dimensions.map(&:name)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def measure_columns
|
|
46
|
+
definition.measures.map(&:name)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def build_discrepancy(row)
|
|
50
|
+
Discrepancy.new(
|
|
51
|
+
cell: key_columns.to_h { |key| [key, row[key]] },
|
|
52
|
+
stored: side(row, "stored"),
|
|
53
|
+
expected: side(row, "expected")
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def side(row, name)
|
|
58
|
+
return nil unless row[:"in_#{name}"].to_i.positive?
|
|
59
|
+
|
|
60
|
+
measure_columns.to_h { |measure| [measure, row[:"#{name}_#{measure}"]] }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def validate_scope!
|
|
64
|
+
return if @scope[:between].nil? || definition.temporal?
|
|
65
|
+
|
|
66
|
+
raise Error, "#{rollup} has no time dimension, so it cannot be verified over a date range"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def connection
|
|
70
|
+
ActiveRecord::Base.connection
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Grain
|
|
4
|
+
# Builds the single statement that compares a rollup against its source.
|
|
5
|
+
#
|
|
6
|
+
# Stored and expected are stacked with UNION ALL and folded with GROUP BY rather
|
|
7
|
+
# than joined. A FULL OUTER JOIN would be the obvious shape, but Postgres only
|
|
8
|
+
# supports one over merge-joinable or hash-joinable conditions, and matching
|
|
9
|
+
# cells needs IS NOT DISTINCT FROM: a dimension resolved from a nullable column
|
|
10
|
+
# has null as a legitimate coordinate, and null = null is never true. Grouping
|
|
11
|
+
# sidesteps the limitation and gets the semantics for free, since GROUP BY
|
|
12
|
+
# already treats nulls as equal.
|
|
13
|
+
class VerificationQuery
|
|
14
|
+
attr_reader :definition, :projection
|
|
15
|
+
|
|
16
|
+
def initialize(definition, tenant: nil, between: nil)
|
|
17
|
+
@definition = definition
|
|
18
|
+
@projection = Projection.new(definition)
|
|
19
|
+
@tenant = tenant
|
|
20
|
+
@between = between
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def to_s
|
|
24
|
+
<<~SQL
|
|
25
|
+
WITH combined AS (
|
|
26
|
+
#{stored_side}
|
|
27
|
+
UNION ALL
|
|
28
|
+
#{expected_side}
|
|
29
|
+
), folded AS (
|
|
30
|
+
SELECT #{folded_selection}
|
|
31
|
+
FROM combined
|
|
32
|
+
GROUP BY #{key_positions}
|
|
33
|
+
)
|
|
34
|
+
SELECT * FROM folded
|
|
35
|
+
WHERE #{disagreement}
|
|
36
|
+
ORDER BY #{key_positions}
|
|
37
|
+
SQL
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def key_columns
|
|
43
|
+
projection.key_columns
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def measure_columns
|
|
47
|
+
projection.measure_columns
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def key_positions
|
|
51
|
+
(1..key_columns.length).to_a.join(", ")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def stored_side
|
|
55
|
+
columns = (key_columns + measure_columns).map { |column| quote_column(column) }.join(", ")
|
|
56
|
+
conditions = scope_conditions { |column| quote_column(column) }
|
|
57
|
+
where = conditions.empty? ? "" : " WHERE #{conditions.join(" AND ")}"
|
|
58
|
+
"SELECT #{columns}, 1 AS grain_side FROM #{definition.table_name}#{where}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def expected_side
|
|
62
|
+
<<~SQL.rstrip
|
|
63
|
+
SELECT #{expected_selection}, 2 AS grain_side
|
|
64
|
+
FROM #{projection.from_and_joins.join(" ")}#{expected_where}
|
|
65
|
+
GROUP BY #{projection.dimension_expressions.join(", ")}
|
|
66
|
+
SQL
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def expected_selection
|
|
70
|
+
pairs = key_columns.zip(projection.dimension_expressions) +
|
|
71
|
+
measure_columns.zip(projection.aggregate_expressions)
|
|
72
|
+
pairs.map { |name, expression| "#{expression} AS #{quote_column(name)}" }.join(", ")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def expected_where
|
|
76
|
+
conditions = projection.filter_conditions + scope_conditions { |column| dimension_expression(column) }
|
|
77
|
+
conditions.empty? ? "" : "\n WHERE #{conditions.join(" AND ")}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# At most one row per side per cell, so MAX picks that row's value and the
|
|
81
|
+
# counts say whether the side contributed a row at all — which is what
|
|
82
|
+
# distinguishes "this side had nothing" from "this side had a null".
|
|
83
|
+
def folded_selection
|
|
84
|
+
(key_columns.map { |key| quote_column(key) } + measure_pickers + presence_counts).join(", ")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def measure_pickers
|
|
88
|
+
sides.flat_map do |side, index|
|
|
89
|
+
measure_columns.map do |measure|
|
|
90
|
+
"MAX(CASE WHEN grain_side = #{index} THEN #{quote_column(measure)} END) AS #{side}_#{measure}"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def presence_counts
|
|
96
|
+
sides.map { |side, index| "COUNT(*) FILTER (WHERE grain_side = #{index}) AS in_#{side}" }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def sides
|
|
100
|
+
{ "stored" => 1, "expected" => 2 }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# A cell survives if one side never produced a row, or any measure differs.
|
|
104
|
+
def disagreement
|
|
105
|
+
absent = ["in_stored = 0", "in_expected = 0"]
|
|
106
|
+
differing = measure_columns.map { |measure| "stored_#{measure} IS DISTINCT FROM expected_#{measure}" }
|
|
107
|
+
(absent + differing).join(" OR ")
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def scope_conditions
|
|
111
|
+
conditions = []
|
|
112
|
+
conditions << "#{yield(definition.tenant.name)} = #{quote(@tenant)}" unless @tenant.nil?
|
|
113
|
+
conditions << between_condition(yield(definition.time.name)) unless @between.nil?
|
|
114
|
+
conditions
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def between_condition(column)
|
|
118
|
+
"#{column} BETWEEN #{quote(@between.first)} AND #{quote(@between.last)}"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def dimension_expression(name)
|
|
122
|
+
@dimension_expressions ||= key_columns.zip(projection.dimension_expressions).to_h
|
|
123
|
+
@dimension_expressions.fetch(name)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def quote_column(name)
|
|
127
|
+
ActiveRecord::Base.connection.quote_column_name(name)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def quote(value)
|
|
131
|
+
ActiveRecord::Base.connection.quote(value)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Grain
|
|
4
|
+
# What a verification found, in a shape that suits both a person reading a
|
|
5
|
+
# terminal and a build deciding whether to fail.
|
|
6
|
+
class VerificationReport
|
|
7
|
+
attr_reader :rollup, :discrepancies, :repaired
|
|
8
|
+
|
|
9
|
+
def initialize(rollup:, discrepancies:, repaired: 0)
|
|
10
|
+
@rollup = rollup
|
|
11
|
+
@discrepancies = discrepancies.freeze
|
|
12
|
+
@repaired = repaired
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def clean?
|
|
16
|
+
discrepancies.empty?
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def count
|
|
20
|
+
discrepancies.length
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def count_of(kind)
|
|
24
|
+
discrepancies.count { |discrepancy| discrepancy.kind == kind }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def with_repaired(number)
|
|
28
|
+
self.class.new(rollup: rollup, discrepancies: discrepancies, repaired: number)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def to_s
|
|
32
|
+
return "#{rollup}: agrees with its source" if clean?
|
|
33
|
+
|
|
34
|
+
[summary, *discrepancies.map { |discrepancy| " #{discrepancy}" }].join("\n")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def summary
|
|
40
|
+
tally = Discrepancy::KINDS.filter_map do |kind|
|
|
41
|
+
found = count_of(kind)
|
|
42
|
+
"#{found} #{kind}" if found.positive?
|
|
43
|
+
end
|
|
44
|
+
repair_note = repaired.positive? ? ", #{repaired} repaired" : ""
|
|
45
|
+
"#{rollup}: #{count} cells disagree (#{tally.join(", ")})#{repair_note}"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Grain
|
|
4
|
+
# Which columns of which tables can move a fact row between cells, and so are
|
|
5
|
+
# worth waking a trigger for.
|
|
6
|
+
#
|
|
7
|
+
# Unlike the fact table these can be narrowed exactly: at each hop along a
|
|
8
|
+
# dimension's path the only column that matters is the foreign key leading to
|
|
9
|
+
# the next hop, or the dimension's own column at the end. Narrowing is not an
|
|
10
|
+
# optimisation to add later — without it a single busy table would write a log
|
|
11
|
+
# row on every update it ever takes.
|
|
12
|
+
class WatchedColumns
|
|
13
|
+
attr_reader :definition, :resolver
|
|
14
|
+
|
|
15
|
+
def initialize(definition)
|
|
16
|
+
@definition = definition
|
|
17
|
+
@resolver = TypeResolver.new(definition)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# { table name => [column names] }, with the fact table left out: its measures
|
|
21
|
+
# aggregate arbitrary SQL, so which of its columns feed them cannot be known
|
|
22
|
+
# and every update on it has to be logged.
|
|
23
|
+
def to_h
|
|
24
|
+
collected = {}
|
|
25
|
+
definition.watched_paths.each { |path| add_path(path, collected) }
|
|
26
|
+
definition.filter_associations.each { |association| add_filter(association, collected) }
|
|
27
|
+
collected.delete(resolver.fact_table)
|
|
28
|
+
collected
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def add_path(path, into)
|
|
34
|
+
path.hops.each_with_index.reduce(definition.fact.model) do |model, (hop, index)|
|
|
35
|
+
add_hop(model, hop, path, index, into)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Two columns matter at each step: the foreign key on the table being left,
|
|
40
|
+
# and — once the walk reaches the end — the dimension's own column.
|
|
41
|
+
def add_hop(model, hop, path, index, into)
|
|
42
|
+
reflection = resolver.reflection!(model, hop, path)
|
|
43
|
+
add(into, model.table_name, reflection.foreign_key)
|
|
44
|
+
reflection.klass.tap do |next_model|
|
|
45
|
+
add(into, next_model.table_name, path.column) if last_hop?(path, index)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def last_hop?(path, index)
|
|
50
|
+
index == path.hops.length - 1
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def add_filter(association, into)
|
|
54
|
+
reflection = resolver.reflection!(definition.fact.model, association, association)
|
|
55
|
+
add(into, definition.fact.model.table_name, reflection.foreign_key)
|
|
56
|
+
conditions(association).each_key { |column| add(into, reflection.klass.table_name, column) }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def conditions(association)
|
|
60
|
+
where = definition.fact.where
|
|
61
|
+
where[association] || where[association.to_s] || {}
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def add(into, table, column)
|
|
65
|
+
list = (into[table] ||= [])
|
|
66
|
+
list << column.to_s
|
|
67
|
+
list.uniq!
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
data/lib/grain/worker.rb
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Grain
|
|
4
|
+
# Drains the change log and brings the affected cells back in line with the
|
|
5
|
+
# source.
|
|
6
|
+
#
|
|
7
|
+
# Claiming and applying happen in one transaction: the log rows are deleted and
|
|
8
|
+
# the rollups are rewritten together, so a crash rolls the deletions back and
|
|
9
|
+
# the work is simply done again. Claiming uses SKIP LOCKED, so several workers
|
|
10
|
+
# can drain the same log without waiting on each other or doing the same work
|
|
11
|
+
# twice.
|
|
12
|
+
class Worker
|
|
13
|
+
Entry = Struct.new(:source_table, :row_id, :operation, :previous, keyword_init: true)
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
# Drains until the log is empty or the time budget runs out. Returns the
|
|
17
|
+
# number of log entries applied.
|
|
18
|
+
def drain(limit: Grain.config.batch_size, max_seconds: Grain.config.max_run_seconds)
|
|
19
|
+
applied = 0
|
|
20
|
+
deadline = monotonic + max_seconds
|
|
21
|
+
loop do
|
|
22
|
+
batch = new(limit: limit).call
|
|
23
|
+
applied += batch
|
|
24
|
+
break if batch.zero? || monotonic >= deadline
|
|
25
|
+
end
|
|
26
|
+
applied
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def monotonic
|
|
30
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
attr_reader :limit
|
|
35
|
+
|
|
36
|
+
def initialize(limit: Grain.config.batch_size)
|
|
37
|
+
@limit = limit
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def call
|
|
41
|
+
connection.transaction do
|
|
42
|
+
entries = claim
|
|
43
|
+
next 0 if entries.empty?
|
|
44
|
+
|
|
45
|
+
apply(entries)
|
|
46
|
+
entries.length
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
# Deleting with RETURNING is the claim: inside the transaction the rows are
|
|
53
|
+
# gone, so no other worker can pick them up, and if this transaction fails
|
|
54
|
+
# they come back.
|
|
55
|
+
def claim
|
|
56
|
+
connection.select_all(claim_sql).to_a.map { |row| Entry.new(**row.symbolize_keys) }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def claim_sql
|
|
60
|
+
<<~SQL
|
|
61
|
+
DELETE FROM #{ChangeLog.table_name}
|
|
62
|
+
WHERE id IN (
|
|
63
|
+
SELECT id FROM #{ChangeLog.table_name}
|
|
64
|
+
ORDER BY id
|
|
65
|
+
LIMIT #{limit.to_i}
|
|
66
|
+
FOR UPDATE SKIP LOCKED
|
|
67
|
+
)
|
|
68
|
+
RETURNING source_table, row_id, operation, previous
|
|
69
|
+
SQL
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def apply(entries)
|
|
73
|
+
entries.group_by(&:source_table).each do |table, group|
|
|
74
|
+
Registry.for_table(table).each { |rollup| refresh(rollup, table, group) }
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def refresh(rollup, table, entries)
|
|
79
|
+
cells = affected_cells(rollup, table, entries)
|
|
80
|
+
Recompute.new(rollup.definition).call(cells)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def affected_cells(rollup, table, entries)
|
|
84
|
+
cells = Cells.new(rollup.definition)
|
|
85
|
+
as_fact(cells, table, entries) + as_watched(cells, table, entries)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def as_fact(cells, table, entries)
|
|
89
|
+
return [] unless cells.projection.fact_table == table
|
|
90
|
+
|
|
91
|
+
cells.live_for_facts(entries.map(&:row_id)) + previous_cells(entries) { |json| cells.previous_for_fact(json) }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def as_watched(cells, table, entries)
|
|
95
|
+
cells.projection.hops_for_table(table).flat_map do |hops|
|
|
96
|
+
cells.live_through(hops, entries.map(&:row_id)) +
|
|
97
|
+
previous_cells(entries) { |json| cells.previous_through(hops, json) }
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def previous_cells(entries, &block)
|
|
102
|
+
entries.filter_map(&:previous).flat_map(&block)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def connection
|
|
106
|
+
ActiveRecord::Base.connection
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
data/lib/grain.rb
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/core_ext/string/inflections"
|
|
4
|
+
|
|
5
|
+
require_relative "grain/version"
|
|
6
|
+
require_relative "grain/errors"
|
|
7
|
+
require_relative "grain/configuration"
|
|
8
|
+
require_relative "grain/path"
|
|
9
|
+
require_relative "grain/dimension"
|
|
10
|
+
require_relative "grain/measure"
|
|
11
|
+
require_relative "grain/ratio"
|
|
12
|
+
require_relative "grain/fact"
|
|
13
|
+
require_relative "grain/definition_validator"
|
|
14
|
+
require_relative "grain/definition"
|
|
15
|
+
require_relative "grain/rollup"
|
|
16
|
+
require_relative "grain/rollup_lookup"
|
|
17
|
+
require_relative "grain/schema"
|
|
18
|
+
require_relative "grain/change_log"
|
|
19
|
+
require_relative "grain/type_resolver"
|
|
20
|
+
require_relative "grain/migration"
|
|
21
|
+
require_relative "grain/watched_columns"
|
|
22
|
+
require_relative "grain/triggers"
|
|
23
|
+
require_relative "grain/registry"
|
|
24
|
+
require_relative "grain/join_graph"
|
|
25
|
+
require_relative "grain/projection"
|
|
26
|
+
require_relative "grain/cells"
|
|
27
|
+
require_relative "grain/recompute"
|
|
28
|
+
require_relative "grain/query_sql"
|
|
29
|
+
require_relative "grain/query"
|
|
30
|
+
require_relative "grain/backfill"
|
|
31
|
+
require_relative "grain/worker"
|
|
32
|
+
require_relative "grain/discrepancy"
|
|
33
|
+
require_relative "grain/verification_report"
|
|
34
|
+
require_relative "grain/verification_query"
|
|
35
|
+
require_relative "grain/verification"
|
|
36
|
+
|
|
37
|
+
# Grain keeps dashboard aggregates pre-computed and incrementally up to date
|
|
38
|
+
# inside the application's own Postgres database.
|
|
39
|
+
#
|
|
40
|
+
# What exists so far is the definition layer: a rollup declares its fact, its
|
|
41
|
+
# key dimensions and its measures, and Grain derives the rollup table's shape
|
|
42
|
+
# and the set of tables that have to be watched. Nothing writes to a database
|
|
43
|
+
# yet. See README.md for the design.
|
|
44
|
+
module Grain
|
|
45
|
+
class << self
|
|
46
|
+
def config
|
|
47
|
+
@config ||= Configuration.new
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def configure
|
|
51
|
+
yield config
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def reset_config!
|
|
55
|
+
@config = Configuration.new
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
require_relative "grain/railtie" if defined?(Rails::Railtie)
|