activerecord-materialized 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +37 -0
- data/LICENSE +21 -0
- data/README.md +526 -0
- data/lib/activerecord/materialized/aggregate_analysis.rb +132 -0
- data/lib/activerecord/materialized/async_refresher.rb +105 -0
- data/lib/activerecord/materialized/cache_table_schema.rb +67 -0
- data/lib/activerecord/materialized/cold_read.rb +60 -0
- data/lib/activerecord/materialized/configuration.rb +80 -0
- data/lib/activerecord/materialized/delta_maintainer.rb +74 -0
- data/lib/activerecord/materialized/dependency_registry.rb +107 -0
- data/lib/activerecord/materialized/dependency_trackable.rb +48 -0
- data/lib/activerecord/materialized/incremental_maintainer.rb +58 -0
- data/lib/activerecord/materialized/maintenance_delta.rb +82 -0
- data/lib/activerecord/materialized/maintenance_delta_builder.rb +62 -0
- data/lib/activerecord/materialized/maintenance_store.rb +82 -0
- data/lib/activerecord/materialized/metadata/maintenance_payload.rb +33 -0
- data/lib/activerecord/materialized/metadata/schema.rb +84 -0
- data/lib/activerecord/materialized/metadata/timestamps.rb +31 -0
- data/lib/activerecord/materialized/metadata.rb +138 -0
- data/lib/activerecord/materialized/metadata_record.rb +28 -0
- data/lib/activerecord/materialized/migration_builder.rb +38 -0
- data/lib/activerecord/materialized/module_api.rb +82 -0
- data/lib/activerecord/materialized/partition_record.rb +27 -0
- data/lib/activerecord/materialized/partition_state.rb +127 -0
- data/lib/activerecord/materialized/query_expressions.rb +83 -0
- data/lib/activerecord/materialized/railtie.rb +16 -0
- data/lib/activerecord/materialized/refresh_callbacks.rb +62 -0
- data/lib/activerecord/materialized/refresh_job.rb +22 -0
- data/lib/activerecord/materialized/refresh_result.rb +40 -0
- data/lib/activerecord/materialized/refresh_scheduler.rb +54 -0
- data/lib/activerecord/materialized/refresher.rb +139 -0
- data/lib/activerecord/materialized/registry.rb +74 -0
- data/lib/activerecord/materialized/relation_cache_writer.rb +137 -0
- data/lib/activerecord/materialized/schema_verifier.rb +64 -0
- data/lib/activerecord/materialized/summary_delta.rb +76 -0
- data/lib/activerecord/materialized/summary_delta_builder.rb +58 -0
- data/lib/activerecord/materialized/table_model_registry.rb +43 -0
- data/lib/activerecord/materialized/tasks.rb +79 -0
- data/lib/activerecord/materialized/type_reexports.rb +14 -0
- data/lib/activerecord/materialized/version.rb +9 -0
- data/lib/activerecord/materialized/view.rb +79 -0
- data/lib/activerecord/materialized/view_class.rb +8 -0
- data/lib/activerecord/materialized/view_configuration_class_methods.rb +103 -0
- data/lib/activerecord/materialized/view_definition.rb +133 -0
- data/lib/activerecord/materialized/view_incremental_class_methods.rb +142 -0
- data/lib/activerecord/materialized/view_query_access_class_methods.rb +160 -0
- data/lib/activerecord/materialized/view_refresh_policy_class_methods.rb +109 -0
- data/lib/activerecord/materialized/write_change.rb +69 -0
- data/lib/activerecord/materialized.rb +55 -0
- data/lib/activerecord_materialized_types.rb +18 -0
- data/lib/generators/activerecord_materialized/install/templates/README +55 -0
- data/lib/generators/activerecord_materialized/install/templates/create_ar_materialized_view_metadata.rb.erb +30 -0
- data/lib/generators/activerecord_materialized/install_generator.rb +32 -0
- data/lib/generators/activerecord_materialized/migration_generator.rb +51 -0
- data/lib/generators/activerecord_materialized/templates/materialized_view.rb.erb +17 -0
- data/lib/generators/activerecord_materialized/templates/materialized_view_migration.rb.erb +11 -0
- data/lib/generators/activerecord_materialized/view_generator.rb +18 -0
- metadata +162 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
module Materialized
|
|
6
|
+
# Tracks every defined view class and provides bulk operations
|
|
7
|
+
# (refresh / rebuild / warm-up / verify) across all of them.
|
|
8
|
+
#
|
|
9
|
+
# @api private
|
|
10
|
+
class Registry
|
|
11
|
+
class << self
|
|
12
|
+
extend T::Sig
|
|
13
|
+
|
|
14
|
+
sig { params(view_class: ViewClass).void }
|
|
15
|
+
def register(view_class)
|
|
16
|
+
views[view_class.view_key] = view_class
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
sig { params(view_class: ViewClass).void }
|
|
20
|
+
def unregister(view_class)
|
|
21
|
+
views.delete(view_class.view_key)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
sig { params(key: T.any(String, Symbol)).returns(T.nilable(ViewClass)) }
|
|
25
|
+
def find(key)
|
|
26
|
+
views[key.to_s]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
sig { params(class_name: String).returns(T.nilable(ViewClass)) }
|
|
30
|
+
def for_class_name(class_name)
|
|
31
|
+
all.find { |view| view.name == class_name }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
sig { returns(T::Array[ViewClass]) }
|
|
35
|
+
def all
|
|
36
|
+
views.values
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Incremental pass over every registered view; rebuild_all! for a full one.
|
|
40
|
+
sig { returns(T::Array[RefreshResult]) }
|
|
41
|
+
def refresh_all!
|
|
42
|
+
all.map(&:refresh!)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
sig { returns(T::Array[RefreshResult]) }
|
|
46
|
+
def refresh_stale!
|
|
47
|
+
all.select(&:stale?).map(&:refresh!)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
sig { returns(T::Array[RefreshResult]) }
|
|
51
|
+
def rebuild_all!
|
|
52
|
+
all.map { |view| view.rebuild!(confirm: true) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
sig { returns(T::Array[T.nilable(RefreshResult)]) }
|
|
56
|
+
def warm_up_all!
|
|
57
|
+
all.map(&:warm_up!)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
sig { void }
|
|
61
|
+
def reset!
|
|
62
|
+
@views = {}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
sig { returns(T::Hash[String, ViewClass]) }
|
|
68
|
+
def views
|
|
69
|
+
@views ||= T.let({}, T.nilable(T::Hash[String, ViewClass]))
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
module Materialized
|
|
6
|
+
# Materializes a relation into a cache table via `INSERT … SELECT`, with an atomic swap on full refresh.
|
|
7
|
+
#
|
|
8
|
+
# @api private
|
|
9
|
+
class RelationCacheWriter
|
|
10
|
+
extend T::Sig
|
|
11
|
+
|
|
12
|
+
sig { params(view_class: T.class_of(::ActiveRecord::Base)).void }
|
|
13
|
+
def initialize(view_class)
|
|
14
|
+
@view_class = view_class
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
sig { params(relation: ::ActiveRecord::Relation).returns(Integer) }
|
|
18
|
+
def bootstrap!(relation)
|
|
19
|
+
CacheTableSchema.ensure_table!(view_class, relation)
|
|
20
|
+
replace_all!(relation)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
sig { params(relation: ::ActiveRecord::Relation).returns(Integer) }
|
|
24
|
+
def replace_all!(relation)
|
|
25
|
+
view_class.transaction do
|
|
26
|
+
view_class.delete_all
|
|
27
|
+
insert_rows!(relation)
|
|
28
|
+
end
|
|
29
|
+
cache_row_count
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
sig do
|
|
33
|
+
params(
|
|
34
|
+
relation: ::ActiveRecord::Relation,
|
|
35
|
+
key_tuples: T::Array[T::Array[String]],
|
|
36
|
+
full_partition: T::Boolean
|
|
37
|
+
).returns(Integer)
|
|
38
|
+
end
|
|
39
|
+
def replace_partitions!(relation, key_tuples:, full_partition:)
|
|
40
|
+
view_class.transaction do
|
|
41
|
+
if full_partition
|
|
42
|
+
view_class.delete_all
|
|
43
|
+
else
|
|
44
|
+
delete_partitions!(key_tuples)
|
|
45
|
+
end
|
|
46
|
+
insert_rows!(relation)
|
|
47
|
+
end
|
|
48
|
+
cache_row_count
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
sig { params(relation: ::ActiveRecord::Relation).returns(Integer) }
|
|
52
|
+
def atomic_swap!(relation)
|
|
53
|
+
connection = view_class.connection
|
|
54
|
+
temp_table = refresh_temp_table_name
|
|
55
|
+
old_table = refresh_old_table_name
|
|
56
|
+
|
|
57
|
+
populate_temp_table!(temp_table, relation)
|
|
58
|
+
swap_tables!(connection, temp_table, old_table)
|
|
59
|
+
|
|
60
|
+
view_class.reset_column_information
|
|
61
|
+
cache_row_count
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
sig { params(temp_table: String, relation: ::ActiveRecord::Relation).void }
|
|
65
|
+
def populate_temp_table!(temp_table, relation)
|
|
66
|
+
CacheTableSchema.create_table!(T.cast(view_class, ViewClass), temp_table, relation)
|
|
67
|
+
temp_model = temporary_model(temp_table)
|
|
68
|
+
self.class.new(temp_model).replace_all!(relation)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
sig { params(connection: Connection, temp_table: String, old_table: String).void }
|
|
72
|
+
def swap_tables!(connection, temp_table, old_table)
|
|
73
|
+
connection.transaction do
|
|
74
|
+
connection.rename_table(view_class.table_name, old_table) if view_class.table_exists?
|
|
75
|
+
connection.rename_table(temp_table, view_class.table_name)
|
|
76
|
+
connection.drop_table(old_table, if_exists: true)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
sig { returns(String) }
|
|
81
|
+
def refresh_temp_table_name
|
|
82
|
+
"#{view_class.table_name}_refresh_#{SecureRandom.hex(4)}"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
sig { returns(String) }
|
|
86
|
+
def refresh_old_table_name
|
|
87
|
+
"#{view_class.table_name}_old_#{SecureRandom.hex(4)}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
sig { returns(T.class_of(::ActiveRecord::Base)) }
|
|
93
|
+
attr_reader :view_class
|
|
94
|
+
|
|
95
|
+
# Count straight from the cache table, bypassing the View read routing
|
|
96
|
+
# (during a rebuild the view is not warm yet, so a routed count would read
|
|
97
|
+
# through to the source).
|
|
98
|
+
sig { returns(Integer) }
|
|
99
|
+
def cache_row_count
|
|
100
|
+
T.unsafe(view_class).unscoped.count
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
sig { params(key_tuples: T::Array[T::Array[String]]).void }
|
|
104
|
+
def delete_partitions!(key_tuples)
|
|
105
|
+
materialized_view = T.cast(view_class, ViewClass)
|
|
106
|
+
materialized_view.view_definition.partition_scope_on(view_class, key_tuples).delete_all
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# INSERT ... SELECT entirely in the database; the result set never crosses
|
|
110
|
+
# into Ruby. Cache columns share the relation's projection order, so the
|
|
111
|
+
# SELECT list maps onto them positionally.
|
|
112
|
+
sig { params(relation: ::ActiveRecord::Relation).void }
|
|
113
|
+
def insert_rows!(relation)
|
|
114
|
+
columns = view_class.column_names - ["id"]
|
|
115
|
+
return if columns.empty?
|
|
116
|
+
|
|
117
|
+
T.unsafe(view_class.connection).execute(insert_select_sql(relation, columns))
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
sig { params(relation: ::ActiveRecord::Relation, columns: T::Array[String]).returns(String) }
|
|
121
|
+
def insert_select_sql(relation, columns)
|
|
122
|
+
manager = Arel::InsertManager.new
|
|
123
|
+
manager.into(view_class.arel_table)
|
|
124
|
+
T.unsafe(manager).columns.concat(columns.map { |name| view_class.arel_table[name] })
|
|
125
|
+
manager.select(Arel.sql(relation.to_sql))
|
|
126
|
+
manager.to_sql
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
sig { params(table_name: String).returns(T.class_of(::ActiveRecord::Base)) }
|
|
130
|
+
def temporary_model(table_name)
|
|
131
|
+
klass = Class.new(::ActiveRecord::Base)
|
|
132
|
+
T.unsafe(klass).table_name = table_name
|
|
133
|
+
klass
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
module Materialized
|
|
6
|
+
# Raises when a provisioned cache table no longer matches the columns its
|
|
7
|
+
# source relation projects (drift); never alters the table or rebuilds data.
|
|
8
|
+
class SchemaVerifier
|
|
9
|
+
extend T::Sig
|
|
10
|
+
|
|
11
|
+
# Raised when a cache table no longer matches the columns its source relation projects.
|
|
12
|
+
class SchemaDriftError < StandardError; end
|
|
13
|
+
|
|
14
|
+
sig { params(view_class: ViewClass).void }
|
|
15
|
+
def initialize(view_class)
|
|
16
|
+
@view_class = view_class
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# An unprovisioned cache table is absent, not drifted, so this is a no-op.
|
|
20
|
+
sig { void }
|
|
21
|
+
def verify!
|
|
22
|
+
return unless @view_class.table_exists?
|
|
23
|
+
|
|
24
|
+
missing = expected_columns - actual_columns
|
|
25
|
+
extra = actual_columns - expected_columns
|
|
26
|
+
return if missing.empty? && extra.empty?
|
|
27
|
+
|
|
28
|
+
Kernel.raise SchemaDriftError, drift_message(missing, extra)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
sig { returns(T::Boolean) }
|
|
32
|
+
def drifted?
|
|
33
|
+
verify!
|
|
34
|
+
false
|
|
35
|
+
rescue SchemaDriftError
|
|
36
|
+
true
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
sig { returns(T::Array[String]) }
|
|
42
|
+
def expected_columns
|
|
43
|
+
CacheTableSchema
|
|
44
|
+
.column_definitions(@view_class.connection, @view_class.resolved_source)
|
|
45
|
+
.map(&:name).sort
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
sig { returns(T::Array[String]) }
|
|
49
|
+
def actual_columns
|
|
50
|
+
@view_class.connection.columns(@view_class.table_name).map(&:name).reject { |name| name == "id" }.sort
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
sig { params(missing: T::Array[String], extra: T::Array[String]).returns(String) }
|
|
54
|
+
def drift_message(missing, extra)
|
|
55
|
+
details = []
|
|
56
|
+
details << "missing columns: #{missing.join(', ')}" if missing.any?
|
|
57
|
+
details << "unexpected columns: #{extra.join(', ')}" if extra.any?
|
|
58
|
+
"#{@view_class.name} cache table #{@view_class.table_name} is out of date " \
|
|
59
|
+
"(#{details.join('; ')}). Generate and run a migration: " \
|
|
60
|
+
"bin/rails generate activerecord_materialized:migration #{@view_class.name}"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
module Materialized
|
|
6
|
+
# Accumulates signed per-partition, per-column numeric changes for a
|
|
7
|
+
# delta-maintainable view (`partition key tuple => mv column => amount`).
|
|
8
|
+
#
|
|
9
|
+
# @api private
|
|
10
|
+
class SummaryDelta
|
|
11
|
+
extend T::Sig
|
|
12
|
+
|
|
13
|
+
KeyTuple = T.type_alias { T::Array[T.untyped] }
|
|
14
|
+
Columns = T.type_alias { T::Hash[String, Numeric] }
|
|
15
|
+
Buckets = T.type_alias { T::Hash[KeyTuple, Columns] }
|
|
16
|
+
|
|
17
|
+
sig { returns(Buckets) }
|
|
18
|
+
attr_reader :buckets
|
|
19
|
+
|
|
20
|
+
sig { params(buckets: Buckets).void }
|
|
21
|
+
def initialize(buckets = {})
|
|
22
|
+
@buckets = buckets
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
sig { params(key_tuple: KeyTuple, column: String, amount: Numeric).void }
|
|
26
|
+
def add(key_tuple, column, amount)
|
|
27
|
+
bucket = (@buckets[key_tuple] ||= {})
|
|
28
|
+
bucket[column] = (bucket[column] || 0) + amount
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
sig { returns(T::Boolean) }
|
|
32
|
+
def empty?
|
|
33
|
+
@buckets.empty?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Number of distinct partitions (buckets) accumulated so far.
|
|
37
|
+
sig { returns(Integer) }
|
|
38
|
+
def tracked_partition_count
|
|
39
|
+
@buckets.size
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Drops net-zero columns (no-op changes) and the partitions left empty.
|
|
43
|
+
sig { returns(SummaryDelta) }
|
|
44
|
+
def prune!
|
|
45
|
+
@buckets.each_value { |columns| columns.reject! { |_column, amount| amount.zero? } }
|
|
46
|
+
@buckets.reject! { |_key_tuple, columns| columns.empty? }
|
|
47
|
+
self
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
sig { params(other: SummaryDelta).returns(SummaryDelta) }
|
|
51
|
+
def merge(other)
|
|
52
|
+
merged = SummaryDelta.new(@buckets.transform_values(&:dup))
|
|
53
|
+
other.buckets.each do |key_tuple, columns|
|
|
54
|
+
columns.each { |column, amount| merged.add(key_tuple, column, amount) }
|
|
55
|
+
end
|
|
56
|
+
merged
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# A list of `"key" => tuple, "columns" => …` entries so array keys survive JSON.
|
|
60
|
+
sig { returns(T::Hash[String, T.untyped]) }
|
|
61
|
+
def serialize
|
|
62
|
+
{ "summary" => @buckets.map { |key_tuple, columns| { "key" => key_tuple, "columns" => columns } } }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
sig { params(payload: T.nilable(T::Hash[String, T.untyped])).returns(T.nilable(SummaryDelta)) }
|
|
66
|
+
def self.deserialize(payload)
|
|
67
|
+
rows = payload && payload["summary"]
|
|
68
|
+
return nil if rows.nil?
|
|
69
|
+
|
|
70
|
+
buckets = T.let({}, Buckets)
|
|
71
|
+
rows.each { |row| buckets[row.fetch("key")] = row.fetch("columns") }
|
|
72
|
+
new(buckets)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
module Materialized
|
|
6
|
+
# The SummaryDelta a single write contributes. Subtracting the "before"
|
|
7
|
+
# snapshot and adding the "after" uniformly handles inserts, deletes, in-place
|
|
8
|
+
# updates, and group-key changes (a move between partitions).
|
|
9
|
+
class SummaryDeltaBuilder
|
|
10
|
+
extend T::Sig
|
|
11
|
+
|
|
12
|
+
sig { params(change: WriteChange, analysis: AggregateAnalysis, group_columns: T::Array[String]).void }
|
|
13
|
+
def initialize(change, analysis, group_columns)
|
|
14
|
+
@change = change
|
|
15
|
+
@analysis = analysis
|
|
16
|
+
@group_columns = group_columns
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
sig { returns(SummaryDelta) }
|
|
20
|
+
def build
|
|
21
|
+
delta = SummaryDelta.new
|
|
22
|
+
apply!(delta, @change.before, -1) unless @change.before.empty?
|
|
23
|
+
apply!(delta, @change.after, 1) unless @change.after.empty?
|
|
24
|
+
delta.prune!
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
sig { params(delta: SummaryDelta, snapshot: T::Hash[String, T.untyped], sign: Integer).void }
|
|
30
|
+
def apply!(delta, snapshot, sign)
|
|
31
|
+
key_tuple = key_tuple(snapshot)
|
|
32
|
+
return if key_tuple.nil?
|
|
33
|
+
|
|
34
|
+
@analysis.aggregate_columns.each do |column|
|
|
35
|
+
contribution = contribution_for(column, snapshot)
|
|
36
|
+
delta.add(key_tuple, column.name, sign * contribution) unless contribution.zero?
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
sig { params(column: AggregateAnalysis::Column, snapshot: T::Hash[String, T.untyped]).returns(Numeric) }
|
|
41
|
+
def contribution_for(column, snapshot)
|
|
42
|
+
case column.function
|
|
43
|
+
when :count_star then 1
|
|
44
|
+
when :count then snapshot[T.must(column.attribute)].nil? ? 0 : 1
|
|
45
|
+
when :sum then snapshot.fetch(T.must(column.attribute), 0) || 0
|
|
46
|
+
else 0
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
sig { params(snapshot: T::Hash[String, T.untyped]).returns(T.nilable(SummaryDelta::KeyTuple)) }
|
|
51
|
+
def key_tuple(snapshot)
|
|
52
|
+
return nil unless @group_columns.all? { |column| snapshot.key?(column) }
|
|
53
|
+
|
|
54
|
+
@group_columns.map { |column| snapshot.fetch(column) }
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
module Materialized
|
|
6
|
+
# Maps table names to their ActiveRecord models for dependency wiring.
|
|
7
|
+
#
|
|
8
|
+
# @api private
|
|
9
|
+
class TableModelRegistry
|
|
10
|
+
class << self
|
|
11
|
+
extend T::Sig
|
|
12
|
+
|
|
13
|
+
sig { params(model_class: T.class_of(::ActiveRecord::Base)).void }
|
|
14
|
+
def register(model_class)
|
|
15
|
+
return if model_class.abstract_class?
|
|
16
|
+
|
|
17
|
+
explicit[model_class.table_name] = model_class
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
sig { params(table_name: String).returns(T.nilable(T.class_of(::ActiveRecord::Base))) }
|
|
21
|
+
def resolve(table_name)
|
|
22
|
+
explicit[table_name] || find_descendant(table_name)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
sig { returns(T::Hash[String, T.class_of(::ActiveRecord::Base)]) }
|
|
28
|
+
def explicit
|
|
29
|
+
@explicit ||= T.let({}, T.nilable(T::Hash[String, T.class_of(::ActiveRecord::Base)]))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
sig { params(table_name: String).returns(T.nilable(T.class_of(::ActiveRecord::Base))) }
|
|
33
|
+
def find_descendant(table_name)
|
|
34
|
+
::ActiveRecord::Base.descendants.find do |model_class|
|
|
35
|
+
!model_class.abstract_class? && model_class.table_name == table_name
|
|
36
|
+
end
|
|
37
|
+
rescue StandardError
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
module Materialized
|
|
6
|
+
# Defines the `materialized:*` rake tasks (refresh_all, refresh_stale, rebuild, verify, warm_up).
|
|
7
|
+
module Tasks
|
|
8
|
+
extend T::Sig
|
|
9
|
+
|
|
10
|
+
DEFINITIONS = T.let(
|
|
11
|
+
{
|
|
12
|
+
refresh_all: "Refresh all registered materialized views",
|
|
13
|
+
refresh_stale: "Refresh stale materialized views",
|
|
14
|
+
rebuild: "Rebuild (fully materialize) all registered materialized views",
|
|
15
|
+
verify: "Verify materialized view cache tables match their source relations",
|
|
16
|
+
warm_up: "Materialize each view's configured warm_up partitions"
|
|
17
|
+
}.freeze,
|
|
18
|
+
T::Hash[Symbol, String]
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
sig { void }
|
|
22
|
+
def self.define!
|
|
23
|
+
application = T.let(T.unsafe(::Rake.application), T.untyped)
|
|
24
|
+
application.instance_eval do
|
|
25
|
+
T.bind(self, T.untyped)
|
|
26
|
+
|
|
27
|
+
namespace :materialized do
|
|
28
|
+
DEFINITIONS.each do |task_name, description|
|
|
29
|
+
desc description
|
|
30
|
+
task(task_name => :environment) { Tasks.run!(task_name) }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
sig { params(task_name: Symbol).void }
|
|
37
|
+
def self.run!(task_name)
|
|
38
|
+
case task_name
|
|
39
|
+
when :refresh_all then run_refresh_all!
|
|
40
|
+
when :refresh_stale then run_refresh_stale!
|
|
41
|
+
when :rebuild then run_rebuild_all!
|
|
42
|
+
when :verify then run_verify!
|
|
43
|
+
when :warm_up then run_warm_up_all!
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
sig { void }
|
|
48
|
+
def self.run_refresh_all!
|
|
49
|
+
Registry.refresh_all!
|
|
50
|
+
T.unsafe(Rails).logger.debug { "Refreshed #{Registry.all.size} materialized view(s)." }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
sig { void }
|
|
54
|
+
def self.run_refresh_stale!
|
|
55
|
+
stale = Registry.all.select(&:stale?)
|
|
56
|
+
stale.each(&:refresh!)
|
|
57
|
+
T.unsafe(Rails).logger.debug { "Refreshed #{stale.size} stale materialized view(s)." }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
sig { void }
|
|
61
|
+
def self.run_rebuild_all!
|
|
62
|
+
Registry.rebuild_all!
|
|
63
|
+
T.unsafe(Rails).logger.debug { "Rebuilt #{Registry.all.size} materialized view(s)." }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
sig { void }
|
|
67
|
+
def self.run_verify!
|
|
68
|
+
ActiveRecord::Materialized.verify_schema!
|
|
69
|
+
T.unsafe(Rails).logger.debug { "Verified #{Registry.all.size} materialized view schema(s)." }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
sig { void }
|
|
73
|
+
def self.run_warm_up_all!
|
|
74
|
+
Registry.warm_up_all!
|
|
75
|
+
T.unsafe(Rails).logger.debug { "Warmed up #{Registry.all.size} materialized view(s)." }
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
module Materialized
|
|
6
|
+
DebounceInterval = T.type_alias { ::ActiveRecordMaterializedTypes::DebounceInterval }
|
|
7
|
+
StalenessDuration = T.type_alias { ::ActiveRecordMaterializedTypes::StalenessDuration }
|
|
8
|
+
SourceDefinition = T.type_alias { ::ActiveRecordMaterializedTypes::SourceDefinition }
|
|
9
|
+
RefreshMode = T.type_alias { ::ActiveRecordMaterializedTypes::RefreshMode }
|
|
10
|
+
RefreshCallbackName = T.type_alias { ::ActiveRecordMaterializedTypes::RefreshCallbackName }
|
|
11
|
+
Connection = T.type_alias { ::ActiveRecordMaterializedTypes::Connection }
|
|
12
|
+
Timestamp = T.type_alias { ::ActiveRecordMaterializedTypes::Timestamp }
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
module Materialized
|
|
6
|
+
# The base class for an application-level materialized view. Subclass it,
|
|
7
|
+
# point {ViewConfigurationClassMethods::ClassMethods#materialized_from} at an
|
|
8
|
+
# `ActiveRecord::Relation`, declare the models it
|
|
9
|
+
# {ViewConfigurationClassMethods::ClassMethods#depends_on depends_on}, and then
|
|
10
|
+
# read it like any ActiveRecord model. Reads are served from a cache table the
|
|
11
|
+
# gem maintains incrementally as the underlying data changes; a full
|
|
12
|
+
# materialization happens only via an explicit
|
|
13
|
+
# {ViewQueryAccessClassMethods::ClassMethods#rebuild! rebuild!}, and until then
|
|
14
|
+
# reads transparently fall through to the source query.
|
|
15
|
+
#
|
|
16
|
+
# @example Define a view and use it
|
|
17
|
+
# class RegionRevenue < ActiveRecord::Materialized::View
|
|
18
|
+
# extend ActiveRecord::Materialized::QueryExpressions
|
|
19
|
+
# self.table_name = "mv_region_revenue"
|
|
20
|
+
#
|
|
21
|
+
# materialized_from do
|
|
22
|
+
# sales = Sale.arel_table
|
|
23
|
+
# Sale.group(:region).select(sales[:region], sum_as(sales[:amount], as: :revenue))
|
|
24
|
+
# end
|
|
25
|
+
#
|
|
26
|
+
# depends_on Sale # writes to Sale schedule maintenance
|
|
27
|
+
# refresh_on_change :async # refresh in the background after commit
|
|
28
|
+
# max_staleness 6.hours # optional time-based safety net
|
|
29
|
+
# end
|
|
30
|
+
#
|
|
31
|
+
# RegionRevenue.rebuild!(confirm: true) # materialize once (e.g. at deploy)
|
|
32
|
+
# RegionRevenue.where(region: "west").pick(:revenue) # served from the cache table
|
|
33
|
+
#
|
|
34
|
+
# @see ViewConfigurationClassMethods::ClassMethods +materialized_from+ / +depends_on+ DSL
|
|
35
|
+
# @see ViewRefreshPolicyClassMethods::ClassMethods refresh strategy, staleness, and warm-up
|
|
36
|
+
# @see ViewIncrementalClassMethods::ClassMethods incremental-maintenance configuration
|
|
37
|
+
# @see ViewQueryAccessClassMethods::ClassMethods +rebuild!+, +refresh!+, +materialized?+, …
|
|
38
|
+
class View < ::ActiveRecord::Base
|
|
39
|
+
extend T::Sig
|
|
40
|
+
include RefreshCallbacks
|
|
41
|
+
include ViewConfigurationClassMethods
|
|
42
|
+
include ViewQueryAccessClassMethods
|
|
43
|
+
|
|
44
|
+
self.abstract_class = true
|
|
45
|
+
|
|
46
|
+
class << self
|
|
47
|
+
extend T::Sig
|
|
48
|
+
|
|
49
|
+
@source_definition = T.let(nil, T.nilable(SourceDefinition))
|
|
50
|
+
@max_staleness_setting = T.let(nil, T.nilable(T.any(StalenessDuration, Proc)))
|
|
51
|
+
@dependency_tables = T.let(nil, T.nilable(T::Array[String]))
|
|
52
|
+
@refresh_strategy = T.let(nil, T.nilable(Symbol))
|
|
53
|
+
@refresh_debounce = T.let(nil, T.nilable(DebounceInterval))
|
|
54
|
+
@refresh_mode = T.let(nil, T.nilable(RefreshMode))
|
|
55
|
+
@incremental_source_definition = T.let(nil, T.nilable(SourceDefinition))
|
|
56
|
+
@incremental_key_columns = T.let(nil, T.nilable(T::Array[String]))
|
|
57
|
+
@table_name = T.let(nil, T.nilable(String))
|
|
58
|
+
|
|
59
|
+
sig { returns(T.nilable(SourceDefinition)) }
|
|
60
|
+
attr_reader :source_definition
|
|
61
|
+
|
|
62
|
+
sig { returns(T.nilable(T.any(StalenessDuration, Proc))) }
|
|
63
|
+
attr_reader :max_staleness_setting
|
|
64
|
+
|
|
65
|
+
sig { returns(T::Array[String]) }
|
|
66
|
+
def dependency_tables
|
|
67
|
+
tables = T.let(T.unsafe(self).instance_variable_get(:@dependency_tables), T.nilable(T::Array[String]))
|
|
68
|
+
tables.nil? ? [] : tables
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
sig { returns(T::Boolean) }
|
|
73
|
+
def stale?
|
|
74
|
+
T.bind(self, View)
|
|
75
|
+
self.class.stale?
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|