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,132 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
module Materialized
|
|
6
|
+
# Decides whether a view can be maintained by applying signed deltas
|
|
7
|
+
# (summary-delta IVM) instead of re-aggregating a partition's base rows. True
|
|
8
|
+
# only for a single-table GROUP BY without HAVING whose aggregates are all
|
|
9
|
+
# distributive (SUM/COUNT/COUNT(*)) and which carries a trustworthy row count
|
|
10
|
+
# so emptied partitions can be detected; everything else falls back to scoped
|
|
11
|
+
# recompute, which is always correct.
|
|
12
|
+
class AggregateAnalysis
|
|
13
|
+
extend T::Sig
|
|
14
|
+
|
|
15
|
+
# One classified aggregate column from a view's projection.
|
|
16
|
+
#
|
|
17
|
+
# @api private
|
|
18
|
+
class Column < T::Struct
|
|
19
|
+
const :name, String
|
|
20
|
+
const :function, Symbol
|
|
21
|
+
const :attribute, T.nilable(String)
|
|
22
|
+
const :counts_rows, T::Boolean, default: false
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
sig { params(relation: ::ActiveRecord::Relation).void }
|
|
26
|
+
def initialize(relation)
|
|
27
|
+
@relation = relation
|
|
28
|
+
@aggregate_columns = T.let(nil, T.nilable(T::Array[Column]))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
sig { returns(T::Array[Column]) }
|
|
32
|
+
def aggregate_columns
|
|
33
|
+
@aggregate_columns ||= T.unsafe(@relation).select_values.filter_map { |value| classify(value) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
sig { returns(T::Boolean) }
|
|
37
|
+
def delta_maintainable?
|
|
38
|
+
single_table? && grouped? && !having? && distributive_aggregates? && aggregate_columns.any?(&:counts_rows)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
sig { returns(T::Boolean) }
|
|
42
|
+
def distributive_aggregates?
|
|
43
|
+
aggregate_columns.any? && aggregate_columns.all? { |column| distributive?(column) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
sig { returns(T.nilable(Column)) }
|
|
47
|
+
def row_count_column
|
|
48
|
+
aggregate_columns.find(&:counts_rows)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
sig { params(value: T.untyped).returns(T.nilable(Column)) }
|
|
54
|
+
def classify(value)
|
|
55
|
+
return nil unless value.is_a?(::Arel::Nodes::As)
|
|
56
|
+
|
|
57
|
+
node = value.left
|
|
58
|
+
name = alias_name(value)
|
|
59
|
+
case node
|
|
60
|
+
when ::Arel::Nodes::Sum then Column.new(name: name, function: :sum, attribute: attribute_name(node))
|
|
61
|
+
when ::Arel::Nodes::Avg then Column.new(name: name, function: :avg, attribute: attribute_name(node))
|
|
62
|
+
when ::Arel::Nodes::Min then Column.new(name: name, function: :min, attribute: attribute_name(node))
|
|
63
|
+
when ::Arel::Nodes::Max then Column.new(name: name, function: :max, attribute: attribute_name(node))
|
|
64
|
+
when ::Arel::Nodes::Count then count_column(name, node)
|
|
65
|
+
else Column.new(name: name, function: :other)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
sig { params(name: String, node: T.untyped).returns(Column) }
|
|
70
|
+
def count_column(name, node)
|
|
71
|
+
return Column.new(name: name, function: :count_distinct, attribute: attribute_name(node)) if node.distinct
|
|
72
|
+
return Column.new(name: name, function: :count_star, counts_rows: true) if star?(node)
|
|
73
|
+
|
|
74
|
+
attribute = attribute_name(node)
|
|
75
|
+
Column.new(name: name, function: :count, attribute: attribute, counts_rows: not_null_column?(attribute))
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
sig { params(column: Column).returns(T::Boolean) }
|
|
79
|
+
def distributive?(column)
|
|
80
|
+
case column.function
|
|
81
|
+
# A SUM over a nullable column can be NULL, which a zero delta can't
|
|
82
|
+
# distinguish from 0, so only NOT NULL sums are delta-maintainable.
|
|
83
|
+
when :sum then !column.attribute.nil? && not_null_column?(column.attribute)
|
|
84
|
+
when :count then !column.attribute.nil?
|
|
85
|
+
when :count_star then true
|
|
86
|
+
else false
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
sig { params(node: T.untyped).returns(T.nilable(String)) }
|
|
91
|
+
def attribute_name(node)
|
|
92
|
+
inner = node.expressions.first
|
|
93
|
+
inner.is_a?(::Arel::Attributes::Attribute) ? T.unsafe(inner).name.to_s : nil
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
sig { params(node: T.untyped).returns(T::Boolean) }
|
|
97
|
+
def star?(node)
|
|
98
|
+
inner = node.expressions.first
|
|
99
|
+
!!(inner.is_a?(::Arel::Nodes::SqlLiteral) && inner.to_s == "*")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
sig { params(value: ::Arel::Nodes::As).returns(String) }
|
|
103
|
+
def alias_name(value)
|
|
104
|
+
right = T.unsafe(value).right
|
|
105
|
+
right.respond_to?(:name) ? right.name.to_s : right.to_s
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
sig { params(attribute: T.nilable(String)).returns(T::Boolean) }
|
|
109
|
+
def not_null_column?(attribute)
|
|
110
|
+
return false if attribute.nil?
|
|
111
|
+
|
|
112
|
+
column = T.unsafe(@relation).klass.columns_hash[attribute]
|
|
113
|
+
!column.nil? && !column.null
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
sig { returns(T::Boolean) }
|
|
117
|
+
def single_table?
|
|
118
|
+
T.unsafe(@relation).joins_values.empty? && T.unsafe(@relation).from_clause.value.nil?
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
sig { returns(T::Boolean) }
|
|
122
|
+
def grouped?
|
|
123
|
+
T.unsafe(@relation).group_values.any?
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
sig { returns(T::Boolean) }
|
|
127
|
+
def having?
|
|
128
|
+
!T.unsafe(@relation).having_clause.empty?
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
module Materialized
|
|
6
|
+
# In-process, debounced background refresher — the default `:async` dispatcher.
|
|
7
|
+
#
|
|
8
|
+
# @api private
|
|
9
|
+
class AsyncRefresher
|
|
10
|
+
class << self
|
|
11
|
+
extend T::Sig
|
|
12
|
+
|
|
13
|
+
sig { params(view_class: ViewClass).void }
|
|
14
|
+
def enqueue(view_class)
|
|
15
|
+
interval = view_class.resolved_refresh_debounce
|
|
16
|
+
|
|
17
|
+
mutex.synchronize do
|
|
18
|
+
pending[view_class.view_key] = view_class
|
|
19
|
+
schedule_unlocked(interval)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
sig { void }
|
|
24
|
+
def flush!
|
|
25
|
+
mutex.synchronize do
|
|
26
|
+
cancel_timer_unlocked
|
|
27
|
+
drain_pending_unlocked
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
sig { returns(Integer) }
|
|
32
|
+
def pending_count
|
|
33
|
+
mutex.synchronize { pending.size }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
sig { void }
|
|
37
|
+
def reset!
|
|
38
|
+
mutex.synchronize do
|
|
39
|
+
cancel_timer_unlocked
|
|
40
|
+
pending.clear
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# When paused, refreshes accumulate and run only on an explicit flush! —
|
|
45
|
+
# no background timer fires.
|
|
46
|
+
sig { params(value: T::Boolean).void }
|
|
47
|
+
def paused=(value)
|
|
48
|
+
@paused = T.let(value, T.nilable(T::Boolean))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
sig { returns(T::Boolean) }
|
|
52
|
+
def paused?
|
|
53
|
+
@paused = T.let(@paused, T.nilable(T::Boolean))
|
|
54
|
+
@paused || false
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
sig { returns(T::Hash[String, ViewClass]) }
|
|
60
|
+
def pending
|
|
61
|
+
@pending ||= T.let({}, T.nilable(T::Hash[String, ViewClass]))
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
sig { returns(Mutex) }
|
|
65
|
+
def mutex
|
|
66
|
+
@mutex ||= T.let(Mutex.new, T.nilable(Mutex))
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
sig { params(interval: T.any(Integer, Float)).void }
|
|
70
|
+
def schedule_unlocked(interval)
|
|
71
|
+
cancel_timer_unlocked
|
|
72
|
+
return if paused?
|
|
73
|
+
|
|
74
|
+
@timer_thread = T.let(
|
|
75
|
+
Thread.new do
|
|
76
|
+
sleep(interval) unless interval.zero?
|
|
77
|
+
mutex.synchronize { drain_pending_unlocked }
|
|
78
|
+
end,
|
|
79
|
+
T.nilable(Thread)
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
sig { void }
|
|
84
|
+
def cancel_timer_unlocked
|
|
85
|
+
return unless @timer_thread&.alive?
|
|
86
|
+
|
|
87
|
+
@timer_thread.kill
|
|
88
|
+
@timer_thread = nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
sig { void }
|
|
92
|
+
def drain_pending_unlocked
|
|
93
|
+
views = pending.values
|
|
94
|
+
pending.clear
|
|
95
|
+
|
|
96
|
+
views.each do |view_class|
|
|
97
|
+
next unless view_class.dirty?
|
|
98
|
+
|
|
99
|
+
view_class.refresh!
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
module Materialized
|
|
6
|
+
# Infers a view's cache-table columns from its source relation and provisions the table.
|
|
7
|
+
#
|
|
8
|
+
# @api private
|
|
9
|
+
module CacheTableSchema
|
|
10
|
+
extend T::Sig
|
|
11
|
+
|
|
12
|
+
# An inferred cache-table column: name from the relation projection, type
|
|
13
|
+
# one of :integer, :decimal, :boolean, :datetime, :string.
|
|
14
|
+
class ColumnDefinition < T::Struct
|
|
15
|
+
const :name, String
|
|
16
|
+
const :type, Symbol
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
sig { params(view_class: T.class_of(::ActiveRecord::Base), relation: ::ActiveRecord::Relation).void }
|
|
22
|
+
def ensure_table!(view_class, relation)
|
|
23
|
+
return if view_class.table_exists?
|
|
24
|
+
|
|
25
|
+
build_table!(view_class.connection, view_class.table_name, relation)
|
|
26
|
+
view_class.reset_column_information
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
sig { params(view_class: T.class_of(::ActiveRecord::Base), table_name: String, relation: ::ActiveRecord::Relation).void }
|
|
30
|
+
def create_table!(view_class, table_name, relation)
|
|
31
|
+
build_table!(view_class.connection, table_name, relation)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The inferred MV columns (names from the projection, types from a one-row
|
|
35
|
+
# probe). Shared by table creation, migration generation, and drift checks.
|
|
36
|
+
sig { params(connection: Connection, relation: ::ActiveRecord::Relation).returns(T::Array[ColumnDefinition]) }
|
|
37
|
+
def column_definitions(connection, relation)
|
|
38
|
+
result = connection.exec_query(relation.limit(1).to_sql)
|
|
39
|
+
sample = result.rows.first
|
|
40
|
+
result.columns.each_with_index.map do |name, index|
|
|
41
|
+
ColumnDefinition.new(name: name, type: type_for_value(sample&.at(index)))
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
sig { params(connection: Connection, table_name: String, relation: ::ActiveRecord::Relation).void }
|
|
46
|
+
def build_table!(connection, table_name, relation)
|
|
47
|
+
definitions = column_definitions(connection, relation)
|
|
48
|
+
connection.create_table(table_name) do |table|
|
|
49
|
+
definitions.each { |definition| T.unsafe(table).public_send(definition.type, definition.name) }
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
private_class_method :build_table!
|
|
53
|
+
|
|
54
|
+
sig { params(value: T.untyped).returns(Symbol) }
|
|
55
|
+
def type_for_value(value)
|
|
56
|
+
case value
|
|
57
|
+
when Integer then :integer
|
|
58
|
+
when Float, BigDecimal then :decimal
|
|
59
|
+
when TrueClass, FalseClass then :boolean
|
|
60
|
+
when Time, Date, DateTime, ::ActiveSupport::TimeWithZone then :datetime
|
|
61
|
+
else :string
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
private_class_method :type_for_value
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
module Materialized
|
|
6
|
+
# The relation a read is served from when a view is not yet materialized,
|
|
7
|
+
# per its cold_read strategy. Read-through wraps the live source as a derived
|
|
8
|
+
# table aliased to the cache table name, so where/order/limit/count keep
|
|
9
|
+
# working against the same column names.
|
|
10
|
+
class ColdRead
|
|
11
|
+
extend T::Sig
|
|
12
|
+
|
|
13
|
+
sig { params(view_class: ViewClass).void }
|
|
14
|
+
def initialize(view_class)
|
|
15
|
+
@view_class = view_class
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
sig { returns(T.untyped) }
|
|
19
|
+
def scope
|
|
20
|
+
case @view_class.resolved_cold_read_strategy
|
|
21
|
+
when :read_through
|
|
22
|
+
ensure_skeleton!
|
|
23
|
+
unscoped.from(source_derived_table)
|
|
24
|
+
when :serve_stale
|
|
25
|
+
ensure_skeleton!
|
|
26
|
+
unscoped
|
|
27
|
+
when :raise
|
|
28
|
+
Kernel.raise NotMaterializedError, not_materialized_message
|
|
29
|
+
else
|
|
30
|
+
Kernel.raise ArgumentError, "Unknown cold_read strategy: #{@view_class.resolved_cold_read_strategy}"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
sig { returns(T.untyped) }
|
|
37
|
+
def unscoped
|
|
38
|
+
T.unsafe(@view_class).unscoped
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
sig { returns(Arel::Nodes::SqlLiteral) }
|
|
42
|
+
def source_derived_table
|
|
43
|
+
Arel.sql("(#{@view_class.resolved_source.to_sql}) #{@view_class.quoted_table_name}")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Provisions an empty cache table for column metadata — cheap DDL, no data.
|
|
47
|
+
sig { void }
|
|
48
|
+
def ensure_skeleton!
|
|
49
|
+
return if @view_class.table_exists?
|
|
50
|
+
|
|
51
|
+
CacheTableSchema.ensure_table!(@view_class, @view_class.resolved_source)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
sig { returns(String) }
|
|
55
|
+
def not_materialized_message
|
|
56
|
+
"#{@view_class.name} is not materialized; run #{@view_class.name}.rebuild!(confirm: true)"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
module Materialized
|
|
6
|
+
# Global, app-wide defaults, set via {Materialized.configure}. Individual
|
|
7
|
+
# views can override most of these with the corresponding DSL macro.
|
|
8
|
+
class Configuration
|
|
9
|
+
extend T::Sig
|
|
10
|
+
|
|
11
|
+
# @return [String] name of the metadata table (default "ar_materialized_view_metadata")
|
|
12
|
+
sig { returns(String) }
|
|
13
|
+
attr_accessor :metadata_table_name
|
|
14
|
+
|
|
15
|
+
# @return [String] name of the per-partition freshness table
|
|
16
|
+
sig { returns(String) }
|
|
17
|
+
attr_accessor :partition_table_name
|
|
18
|
+
|
|
19
|
+
# @return [Numeric, ActiveSupport::Duration, nil] default {ViewRefreshPolicyClassMethods::ClassMethods#max_staleness max_staleness}
|
|
20
|
+
sig { returns(T.nilable(DebounceInterval)) }
|
|
21
|
+
attr_accessor :default_max_staleness
|
|
22
|
+
|
|
23
|
+
# @return [Integer, nil] optional per-refresh timeout in seconds
|
|
24
|
+
sig { returns(T.nilable(Integer)) }
|
|
25
|
+
attr_accessor :refresh_timeout
|
|
26
|
+
|
|
27
|
+
# @return [Boolean] whether a full refresh swaps a freshly built table in atomically
|
|
28
|
+
sig { returns(T::Boolean) }
|
|
29
|
+
attr_accessor :atomic_swap_refresh
|
|
30
|
+
|
|
31
|
+
# @return [Symbol] default refresh strategy: +:async+, +:immediate+, or +:manual+
|
|
32
|
+
sig { returns(Symbol) }
|
|
33
|
+
attr_accessor :default_refresh_strategy
|
|
34
|
+
|
|
35
|
+
# @return [Numeric] default debounce window (seconds) for coalescing async refreshes
|
|
36
|
+
sig { returns(DebounceInterval) }
|
|
37
|
+
attr_accessor :default_refresh_debounce
|
|
38
|
+
|
|
39
|
+
# @return [Symbol] background dispatcher: +:async+ (in-process thread) or +:active_job+
|
|
40
|
+
sig { returns(Symbol) }
|
|
41
|
+
attr_accessor :refresh_dispatcher
|
|
42
|
+
|
|
43
|
+
# @return [Symbol] ActiveJob queue name used when +refresh_dispatcher+ is +:active_job+
|
|
44
|
+
sig { returns(Symbol) }
|
|
45
|
+
attr_accessor :refresh_queue_name
|
|
46
|
+
|
|
47
|
+
# Cold-read behavior: :read_through (serve from source), :serve_stale
|
|
48
|
+
# (serve the cache as-is), or :raise.
|
|
49
|
+
sig { returns(Symbol) }
|
|
50
|
+
attr_accessor :default_cold_read_strategy
|
|
51
|
+
|
|
52
|
+
# Cap on distinct partitions tracked in a view's pending maintenance before
|
|
53
|
+
# it collapses to a single full recompute. Bounds the per-write cost of a
|
|
54
|
+
# bulk write that spans many partitions. Defaults to 1000.
|
|
55
|
+
#
|
|
56
|
+
# @return [Integer]
|
|
57
|
+
sig { params(max_tracked_partitions: Integer).void }
|
|
58
|
+
attr_writer :max_tracked_partitions
|
|
59
|
+
|
|
60
|
+
sig { returns(Integer) }
|
|
61
|
+
def max_tracked_partitions
|
|
62
|
+
@max_tracked_partitions ||= T.let(1_000, T.nilable(Integer))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
sig { void }
|
|
66
|
+
def initialize
|
|
67
|
+
@metadata_table_name = T.let("ar_materialized_view_metadata", String)
|
|
68
|
+
@partition_table_name = T.let("ar_materialized_view_partitions", String)
|
|
69
|
+
@default_max_staleness = T.let(nil, T.nilable(DebounceInterval))
|
|
70
|
+
@refresh_timeout = T.let(nil, T.nilable(Integer))
|
|
71
|
+
@atomic_swap_refresh = T.let(true, T::Boolean)
|
|
72
|
+
@default_refresh_strategy = T.let(:async, Symbol)
|
|
73
|
+
@default_refresh_debounce = T.let(2, DebounceInterval)
|
|
74
|
+
@refresh_dispatcher = T.let(:async, Symbol)
|
|
75
|
+
@refresh_queue_name = T.let(:materialized_views, Symbol)
|
|
76
|
+
@default_cold_read_strategy = T.let(:read_through, Symbol)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
module Materialized
|
|
6
|
+
# Applies a SummaryDelta to a delta-maintainable view's cache table without
|
|
7
|
+
# re-reading base rows: new partitions inserted, existing ones incremented in
|
|
8
|
+
# place (NULL-safe for SUM), and emptied partitions deleted.
|
|
9
|
+
class DeltaMaintainer
|
|
10
|
+
extend T::Sig
|
|
11
|
+
|
|
12
|
+
sig { params(view_class: ViewClass).void }
|
|
13
|
+
def initialize(view_class)
|
|
14
|
+
@view_class = view_class
|
|
15
|
+
@analysis = T.let(AggregateAnalysis.new(view_class.resolved_source), AggregateAnalysis)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
sig { params(summary: SummaryDelta).returns(Integer) }
|
|
19
|
+
def apply!(summary)
|
|
20
|
+
summary.buckets.each { |key_tuple, column_deltas| apply_partition(key_tuple, column_deltas) }
|
|
21
|
+
T.unsafe(@view_class).unscoped.count
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
sig { returns(T::Array[String]) }
|
|
27
|
+
def group_columns
|
|
28
|
+
@view_class.maintenance_key_columns
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
sig { params(key_tuple: SummaryDelta::KeyTuple, column_deltas: T::Hash[String, Numeric]).void }
|
|
32
|
+
def apply_partition(key_tuple, column_deltas)
|
|
33
|
+
existing = partition_scope(key_tuple).first
|
|
34
|
+
if existing.nil?
|
|
35
|
+
insert_partition(key_tuple, column_deltas)
|
|
36
|
+
elsif emptied?(existing, column_deltas)
|
|
37
|
+
existing.destroy!
|
|
38
|
+
else
|
|
39
|
+
add_deltas!(existing, column_deltas)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
sig { params(key_tuple: SummaryDelta::KeyTuple).returns(::ActiveRecord::Relation) }
|
|
44
|
+
def partition_scope(key_tuple)
|
|
45
|
+
T.unsafe(@view_class).unscoped.where(group_columns.zip(key_tuple).to_h)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
sig { params(key_tuple: SummaryDelta::KeyTuple, column_deltas: T::Hash[String, Numeric]).void }
|
|
49
|
+
def insert_partition(key_tuple, column_deltas)
|
|
50
|
+
# A new partition's deltas are its absolute values; aggregates with no
|
|
51
|
+
# delta default to 0, not NULL, since the partition has rows.
|
|
52
|
+
defaults = @analysis.aggregate_columns.to_h { |column| [column.name, 0] }
|
|
53
|
+
row = group_columns.zip(key_tuple).to_h.merge(defaults).merge(column_deltas)
|
|
54
|
+
T.unsafe(@view_class).create!(row)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Adds each delta to the current value, treating a NULL SUM as zero.
|
|
58
|
+
sig { params(existing: T.untyped, column_deltas: T::Hash[String, Numeric]).void }
|
|
59
|
+
def add_deltas!(existing, column_deltas)
|
|
60
|
+
new_values = column_deltas.to_h { |column, amount| [column, (existing[column] || 0) + amount] }
|
|
61
|
+
existing.update!(new_values)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
sig { params(existing: T.untyped, column_deltas: T::Hash[String, Numeric]).returns(T::Boolean) }
|
|
65
|
+
def emptied?(existing, column_deltas)
|
|
66
|
+
column = @analysis.row_count_column
|
|
67
|
+
return false if column.nil?
|
|
68
|
+
|
|
69
|
+
delta = column_deltas[column.name] || 0
|
|
70
|
+
(existing[column.name].to_i + delta) <= 0
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
module Materialized
|
|
6
|
+
# Maps dependency tables to the views that depend on them and publishes committed writes to them.
|
|
7
|
+
#
|
|
8
|
+
# @api private
|
|
9
|
+
class DependencyRegistry
|
|
10
|
+
class << self
|
|
11
|
+
extend T::Sig
|
|
12
|
+
|
|
13
|
+
sig do
|
|
14
|
+
params(
|
|
15
|
+
view_class: ViewClass,
|
|
16
|
+
tables: T.any(
|
|
17
|
+
Symbol,
|
|
18
|
+
String,
|
|
19
|
+
T.class_of(::ActiveRecord::Base),
|
|
20
|
+
T::Array[T.any(Symbol, String, T.class_of(::ActiveRecord::Base))]
|
|
21
|
+
)
|
|
22
|
+
).void
|
|
23
|
+
end
|
|
24
|
+
def register(view_class, tables)
|
|
25
|
+
normalized = Array(tables).flat_map { |entry| normalize_dependency(entry) }
|
|
26
|
+
T.unsafe(view_class).instance_variable_set(:@dependency_tables, normalized)
|
|
27
|
+
|
|
28
|
+
normalized.each do |table|
|
|
29
|
+
bucket = T.cast(dependency_index[table], T::Array[ViewClass])
|
|
30
|
+
bucket << view_class unless bucket.include?(view_class)
|
|
31
|
+
subscribe_source_table!(table)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
sig { params(table: String).returns(T::Array[ViewClass]) }
|
|
36
|
+
def views_for_table(table)
|
|
37
|
+
T.must(dependency_index[normalize_table(table)])
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
sig { params(change: WriteChange).void }
|
|
41
|
+
def publish_write_change!(change)
|
|
42
|
+
affected_views([change.table_name]).each do |view|
|
|
43
|
+
view.record_write_change!(change)
|
|
44
|
+
RefreshScheduler.schedule(view)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
sig { params(tables: T::Array[String]).void }
|
|
49
|
+
def mark_dirty_for_tables!(tables)
|
|
50
|
+
affected_views(tables).each(&:mark_dependencies_changed!)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
sig { void }
|
|
54
|
+
def reset!
|
|
55
|
+
@dependency_index = Hash.new { |hash, key| hash[key] = [] }
|
|
56
|
+
ActiveRecord::Materialized::DependencyTrackable.reset!
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
sig { returns(T::Hash[String, T::Array[ViewClass]]) }
|
|
62
|
+
def dependency_index
|
|
63
|
+
@dependency_index ||= T.let(
|
|
64
|
+
Hash.new { |hash, key| hash[key] = [] },
|
|
65
|
+
T.nilable(T::Hash[String, T::Array[ViewClass]])
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
sig { params(tables: T::Array[String]).returns(T::Array[ViewClass]) }
|
|
70
|
+
def affected_views(tables)
|
|
71
|
+
Array(tables).flat_map do |table|
|
|
72
|
+
next [] if skip_table?(table)
|
|
73
|
+
|
|
74
|
+
views_for_table(table)
|
|
75
|
+
end.uniq
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
sig { params(entry: T.untyped).returns(T::Array[String]) }
|
|
79
|
+
def normalize_dependency(entry)
|
|
80
|
+
if entry.is_a?(Class) && entry < ::ActiveRecord::Base
|
|
81
|
+
TableModelRegistry.register(entry)
|
|
82
|
+
return [entry.table_name]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
[normalize_table(entry)]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
sig { params(table: String).void }
|
|
89
|
+
def subscribe_source_table!(table)
|
|
90
|
+
model = TableModelRegistry.resolve(table)
|
|
91
|
+
ActiveRecord::Materialized::DependencyTrackable.subscribe(model) if model
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
sig { params(table: T.any(Symbol, String)).returns(String) }
|
|
95
|
+
def normalize_table(table)
|
|
96
|
+
::ActiveSupport::Inflector.underscore(table.to_s.delete_prefix(":"))
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
sig { params(table: T.any(Symbol, String)).returns(T::Boolean) }
|
|
100
|
+
def skip_table?(table)
|
|
101
|
+
name = normalize_table(table)
|
|
102
|
+
name.start_with?("mv_") || name == ::ActiveRecord::Materialized.metadata_table_name
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
module Materialized
|
|
6
|
+
# Installs `after_*_commit` callbacks on `depends_on` models so their writes schedule maintenance.
|
|
7
|
+
#
|
|
8
|
+
# @api private
|
|
9
|
+
module DependencyTrackable
|
|
10
|
+
extend T::Sig
|
|
11
|
+
|
|
12
|
+
TRACKABLE_FLAG = :@ar_materialized_dependency_trackable
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
extend T::Sig
|
|
16
|
+
|
|
17
|
+
sig { params(model_class: T.class_of(::ActiveRecord::Base)).void }
|
|
18
|
+
def subscribe(model_class)
|
|
19
|
+
return if model_class.instance_variable_get(TRACKABLE_FLAG)
|
|
20
|
+
|
|
21
|
+
install_callbacks!(model_class)
|
|
22
|
+
model_class.instance_variable_set(TRACKABLE_FLAG, true)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Invoked from the model commit callbacks; `record` is the committed instance.
|
|
26
|
+
sig { params(record: ::ActiveRecord::Base, operation: WriteChange::Operation).void }
|
|
27
|
+
def publish(record, operation)
|
|
28
|
+
DependencyRegistry.publish_write_change!(WriteChange.from_record(record, operation))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
sig { void }
|
|
32
|
+
def reset!
|
|
33
|
+
nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
sig { params(model_class: T.class_of(::ActiveRecord::Base)).void }
|
|
39
|
+
def install_callbacks!(model_class)
|
|
40
|
+
model = T.unsafe(model_class)
|
|
41
|
+
model.after_create_commit { DependencyTrackable.publish(T.unsafe(self), :create) }
|
|
42
|
+
model.after_update_commit { DependencyTrackable.publish(T.unsafe(self), :update) }
|
|
43
|
+
model.after_destroy_commit { DependencyTrackable.publish(T.unsafe(self), :destroy) }
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|