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,103 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
module Materialized
|
|
6
|
+
# The `materialized_from` / `depends_on` DSL and source/metadata accessors mixed into every {View}.
|
|
7
|
+
module ViewConfigurationClassMethods
|
|
8
|
+
extend T::Sig
|
|
9
|
+
extend T::Helpers
|
|
10
|
+
|
|
11
|
+
sig { params(base: T.class_of(View)).void }
|
|
12
|
+
def self.included(base)
|
|
13
|
+
base.extend(ClassMethods)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# The configuration DSL methods available on a {View} subclass.
|
|
17
|
+
module ClassMethods
|
|
18
|
+
extend T::Sig
|
|
19
|
+
include ViewIncrementalClassMethods::ClassMethods
|
|
20
|
+
include ViewRefreshPolicyClassMethods::ClassMethods
|
|
21
|
+
|
|
22
|
+
sig { returns(T.class_of(View)) }
|
|
23
|
+
def view_class
|
|
24
|
+
T.cast(self, T.class_of(View))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
sig { params(subclass: T.class_of(View)).void }
|
|
28
|
+
def inherited(subclass)
|
|
29
|
+
super
|
|
30
|
+
T.unsafe(subclass).instance_variable_set(:@dependency_tables, [])
|
|
31
|
+
T.unsafe(subclass).instance_variable_set(:@refresh_strategy, nil)
|
|
32
|
+
T.unsafe(subclass).instance_variable_set(:@refresh_debounce, nil)
|
|
33
|
+
T.unsafe(subclass).instance_variable_set(:@refresh_mode, nil)
|
|
34
|
+
T.unsafe(subclass).instance_variable_set(:@incremental_source_definition, nil)
|
|
35
|
+
T.unsafe(subclass).instance_variable_set(:@incremental_key_columns, nil)
|
|
36
|
+
T.unsafe(subclass).instance_variable_set(:@cold_read_strategy, nil)
|
|
37
|
+
T.unsafe(subclass).instance_variable_set(:@warm_up_definition, nil)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
sig { returns(String) }
|
|
41
|
+
def view_key
|
|
42
|
+
return T.must(view_class.name).underscore if view_class.name.present?
|
|
43
|
+
|
|
44
|
+
table = T.let(T.unsafe(view_class).instance_variable_get(:@table_name), T.nilable(String))
|
|
45
|
+
table.presence || "anonymous_view_#{view_class.object_id}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
sig { params(block: T.proc.returns(::ActiveRecord::Relation)).void }
|
|
49
|
+
def materialized_from(&block)
|
|
50
|
+
@source_definition = T.let(block, T.nilable(SourceDefinition))
|
|
51
|
+
Registry.register(view_class) unless view_class.abstract_class?
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
sig { params(tables: T.any(Symbol, String, T.class_of(::ActiveRecord::Base))).void }
|
|
55
|
+
def depends_on(*tables)
|
|
56
|
+
DependencyRegistry.register(view_class, tables)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
sig { returns(::ActiveRecord::Relation) }
|
|
60
|
+
def resolved_source
|
|
61
|
+
resolve_source_definition(
|
|
62
|
+
@source_definition,
|
|
63
|
+
"materialized_from is required for #{view_class.name || view_class.view_key}"
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
sig { returns(Metadata) }
|
|
68
|
+
def metadata
|
|
69
|
+
@metadata = T.let(@metadata, T.nilable(ActiveRecord::Materialized::Metadata))
|
|
70
|
+
@metadata ||= ActiveRecord::Materialized::Metadata.new(view_class)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
sig do
|
|
76
|
+
params(
|
|
77
|
+
definition: T.nilable(SourceDefinition),
|
|
78
|
+
empty_message: String
|
|
79
|
+
).returns(::ActiveRecord::Relation)
|
|
80
|
+
end
|
|
81
|
+
def resolve_source_definition(definition, empty_message)
|
|
82
|
+
source = coerce_source(definition)
|
|
83
|
+
Kernel.raise ArgumentError, empty_message if source.nil?
|
|
84
|
+
unless source.is_a?(::ActiveRecord::Relation)
|
|
85
|
+
Kernel.raise ArgumentError, "#{empty_message}: expected ActiveRecord::Relation, got #{source.class}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
source
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
sig { params(definition: T.nilable(SourceDefinition)).returns(T.untyped) }
|
|
92
|
+
def coerce_source(definition)
|
|
93
|
+
source = definition
|
|
94
|
+
return source unless source.is_a?(Proc)
|
|
95
|
+
|
|
96
|
+
source.call
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
mixes_in_class_methods ClassMethods
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
module Materialized
|
|
6
|
+
# Inspects a source relation for its GROUP BY maintenance keys and builds partition scopes.
|
|
7
|
+
#
|
|
8
|
+
# @api private
|
|
9
|
+
class ViewDefinition
|
|
10
|
+
extend T::Sig
|
|
11
|
+
|
|
12
|
+
sig do
|
|
13
|
+
params(
|
|
14
|
+
source: ::ActiveRecord::Relation,
|
|
15
|
+
explicit_group_keys: T.nilable(T::Array[String])
|
|
16
|
+
).void
|
|
17
|
+
end
|
|
18
|
+
def initialize(source, explicit_group_keys: nil)
|
|
19
|
+
@source = source
|
|
20
|
+
@explicit_group_keys = explicit_group_keys
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
sig { returns(T::Boolean) }
|
|
24
|
+
def incrementally_maintainable?
|
|
25
|
+
group_key_columns.any?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
sig { returns(T::Array[String]) }
|
|
29
|
+
def group_key_columns
|
|
30
|
+
@group_key_columns = T.let(@group_key_columns, T.nilable(T::Array[String]))
|
|
31
|
+
@group_key_columns ||= resolve_group_key_columns
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Restrict a model/cache table to the given partitions. The partition
|
|
35
|
+
# columns are real columns on `model`, so qualify them to its own table.
|
|
36
|
+
sig do
|
|
37
|
+
params(
|
|
38
|
+
model: T.class_of(::ActiveRecord::Base),
|
|
39
|
+
key_tuples: T::Array[T::Array[T.untyped]]
|
|
40
|
+
).returns(::ActiveRecord::Relation)
|
|
41
|
+
end
|
|
42
|
+
def partition_scope_on(model, key_tuples)
|
|
43
|
+
validate_partition_keys!(key_tuples)
|
|
44
|
+
attributes = group_key_columns.map { |column| T.unsafe(model).arel_table[column] }
|
|
45
|
+
filter_partitions(model.unscoped, attributes, key_tuples)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Restrict the source relation to the given partitions. Qualify each key to
|
|
49
|
+
# its GROUP BY attribute's own table, which may be a joined table (e.g.
|
|
50
|
+
# `name.gender`) rather than the source's base table.
|
|
51
|
+
sig { params(key_tuples: T::Array[T::Array[T.untyped]]).returns(::ActiveRecord::Relation) }
|
|
52
|
+
def partition_scope(key_tuples)
|
|
53
|
+
validate_partition_keys!(key_tuples)
|
|
54
|
+
base = T.unsafe(source).klass.arel_table
|
|
55
|
+
attributes = group_key_columns.map { |column| group_attributes[column] || base[column] }
|
|
56
|
+
filter_partitions(source, attributes, key_tuples)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
sig { returns(::ActiveRecord::Relation) }
|
|
62
|
+
attr_reader :source
|
|
63
|
+
|
|
64
|
+
sig { returns(T::Array[String]) }
|
|
65
|
+
def resolve_group_key_columns
|
|
66
|
+
return @explicit_group_keys if @explicit_group_keys&.any?
|
|
67
|
+
|
|
68
|
+
relation_group_columns
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
sig { returns(T::Array[String]) }
|
|
72
|
+
def relation_group_columns
|
|
73
|
+
source.group_values.filter_map { |group_value| group_column_name(group_value) }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
sig { params(group_value: T.untyped).returns(T.nilable(String)) }
|
|
77
|
+
def group_column_name(group_value)
|
|
78
|
+
case group_value
|
|
79
|
+
when String, Symbol
|
|
80
|
+
group_value.to_s
|
|
81
|
+
when ::Arel::Attributes::Attribute
|
|
82
|
+
T.unsafe(group_value).name.to_s
|
|
83
|
+
else
|
|
84
|
+
group_value.to_s if group_value.respond_to?(:to_s)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
sig { params(key_tuples: T::Array[T::Array[T.untyped]]).void }
|
|
89
|
+
def validate_partition_keys!(key_tuples)
|
|
90
|
+
raise ArgumentError, "scoped maintenance requires GROUP BY keys" unless incrementally_maintainable?
|
|
91
|
+
raise ArgumentError, "scoped maintenance requires at least one partition key" if key_tuples.empty?
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# GROUP BY attributes keyed by name; an Arel attribute carries its real
|
|
95
|
+
# table (e.g. a joined `name.gender`) that the bare base table column lacks.
|
|
96
|
+
sig { returns(T::Hash[String, T.untyped]) }
|
|
97
|
+
def group_attributes
|
|
98
|
+
@group_attributes = T.let(@group_attributes, T.nilable(T::Hash[String, T.untyped]))
|
|
99
|
+
@group_attributes ||= T.unsafe(source).group_values.each_with_object({}) do |value, map|
|
|
100
|
+
map[T.unsafe(value).name.to_s] = value if value.is_a?(::Arel::Attributes::Attribute)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
sig do
|
|
105
|
+
params(
|
|
106
|
+
scope: ::ActiveRecord::Relation,
|
|
107
|
+
attributes: T::Array[T.untyped],
|
|
108
|
+
key_tuples: T::Array[T::Array[T.untyped]]
|
|
109
|
+
).returns(::ActiveRecord::Relation)
|
|
110
|
+
end
|
|
111
|
+
def filter_partitions(scope, attributes, key_tuples)
|
|
112
|
+
return multi_partition_filter(scope, attributes, key_tuples) if attributes.size > 1
|
|
113
|
+
|
|
114
|
+
scope.where(T.unsafe(attributes.fetch(0)).in(key_tuples.map(&:first)))
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
sig do
|
|
118
|
+
params(
|
|
119
|
+
scope: ::ActiveRecord::Relation, attributes: T::Array[T.untyped],
|
|
120
|
+
key_tuples: T::Array[T::Array[T.untyped]]
|
|
121
|
+
).returns(::ActiveRecord::Relation)
|
|
122
|
+
end
|
|
123
|
+
def multi_partition_filter(scope, attributes, key_tuples)
|
|
124
|
+
key_tuples.reduce(T.unsafe(nil)) do |merged_scope, tuple|
|
|
125
|
+
branch = attributes.each_with_index.reduce(scope) do |relation, (attribute, index)|
|
|
126
|
+
relation.where(T.unsafe(attribute).eq(tuple[index]))
|
|
127
|
+
end
|
|
128
|
+
merged_scope.nil? ? branch : merged_scope.or(branch)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
module Materialized
|
|
6
|
+
# Incremental-maintenance DSL mixed into a {View}: `refresh_mode`, `incremental_keys`,
|
|
7
|
+
# `incremental_from`, and the per-write maintenance entry points.
|
|
8
|
+
module ViewIncrementalClassMethods
|
|
9
|
+
extend T::Sig
|
|
10
|
+
extend T::Helpers
|
|
11
|
+
|
|
12
|
+
sig { params(base: T.class_of(View)).void }
|
|
13
|
+
def self.included(base)
|
|
14
|
+
base.extend(ClassMethods)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# The incremental-maintenance DSL methods available on a {View} subclass.
|
|
18
|
+
module ClassMethods
|
|
19
|
+
extend T::Sig
|
|
20
|
+
|
|
21
|
+
sig { returns(T.class_of(View)) }
|
|
22
|
+
def view_class
|
|
23
|
+
T.cast(self, T.class_of(View))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
sig { params(mode: RefreshMode).void }
|
|
27
|
+
def refresh_mode(mode)
|
|
28
|
+
T.unsafe(self).instance_variable_set(:@refresh_mode, mode.to_sym)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
sig { params(block: T.proc.returns(::ActiveRecord::Relation)).void }
|
|
32
|
+
def incremental_from(&block)
|
|
33
|
+
@incremental_source_definition = T.let(block, T.nilable(SourceDefinition))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
sig { params(columns: T.any(Symbol, String)).void }
|
|
37
|
+
def incremental_keys(*columns)
|
|
38
|
+
@incremental_key_columns = T.let(columns.map(&:to_s), T.nilable(T::Array[String]))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
sig { returns(RefreshMode) }
|
|
42
|
+
def resolved_refresh_mode
|
|
43
|
+
mode = T.let(
|
|
44
|
+
T.unsafe(self).instance_variable_get(:@refresh_mode),
|
|
45
|
+
T.nilable(RefreshMode)
|
|
46
|
+
)
|
|
47
|
+
mode || :incremental
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
sig { returns(ViewDefinition) }
|
|
51
|
+
def view_definition
|
|
52
|
+
ViewDefinition.new(
|
|
53
|
+
view_class.resolved_source,
|
|
54
|
+
explicit_group_keys: incremental_key_columns.presence
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
sig { returns(T::Array[String]) }
|
|
59
|
+
def maintenance_key_columns
|
|
60
|
+
return incremental_key_columns if incremental_key_columns.any?
|
|
61
|
+
|
|
62
|
+
view_definition.group_key_columns
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
sig { returns(T::Boolean) }
|
|
66
|
+
def incrementally_maintainable?
|
|
67
|
+
resolved_refresh_mode != :full && view_definition.incrementally_maintainable?
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
sig { returns(AggregateAnalysis) }
|
|
71
|
+
def aggregate_analysis
|
|
72
|
+
AggregateAnalysis.new(view_class.resolved_source)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# A warm view with all-distributive aggregates uses summary-delta IVM;
|
|
76
|
+
# otherwise writes drive scoped recompute.
|
|
77
|
+
sig { returns(T::Boolean) }
|
|
78
|
+
def delta_maintaining?
|
|
79
|
+
resolved_refresh_mode != :full && view_class.materialized? && aggregate_analysis.delta_maintainable?
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
sig { returns(T::Boolean) }
|
|
83
|
+
def incremental_source_override?
|
|
84
|
+
!@incremental_source_definition.nil?
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
sig { params(change: WriteChange).void }
|
|
88
|
+
def record_write_change!(change)
|
|
89
|
+
return record_summary_delta!(change) if delta_maintaining?
|
|
90
|
+
return unless incrementally_maintainable?
|
|
91
|
+
|
|
92
|
+
delta = MaintenanceDeltaBuilder.new(change, maintenance_key_columns).build
|
|
93
|
+
record_write_delta!(delta)
|
|
94
|
+
|
|
95
|
+
# On a cold view the written partitions are no longer current; drop them
|
|
96
|
+
# from the fresh set until re-maintained.
|
|
97
|
+
return if view_class.materialized? || delta.full_partition?
|
|
98
|
+
|
|
99
|
+
PartitionState.new(view_class).mark_stale!(delta.key_tuples)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
sig { params(change: WriteChange).void }
|
|
103
|
+
def record_summary_delta!(change)
|
|
104
|
+
summary = SummaryDeltaBuilder.new(change, aggregate_analysis, maintenance_key_columns).build
|
|
105
|
+
MaintenanceStore.new(view_class).merge!(summary) unless summary.empty?
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
sig { params(delta: MaintenanceDelta).void }
|
|
109
|
+
def record_write_delta!(delta)
|
|
110
|
+
return unless incrementally_maintainable?
|
|
111
|
+
|
|
112
|
+
MaintenanceStore.new(view_class).merge!(delta)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
sig { returns(T::Array[String]) }
|
|
116
|
+
def incremental_key_columns
|
|
117
|
+
columns = T.let(
|
|
118
|
+
T.unsafe(self).instance_variable_get(:@incremental_key_columns),
|
|
119
|
+
T.nilable(T::Array[String])
|
|
120
|
+
)
|
|
121
|
+
columns.nil? ? [] : columns
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
sig { returns(::ActiveRecord::Relation) }
|
|
125
|
+
def resolved_incremental_source
|
|
126
|
+
unless incremental_source_override?
|
|
127
|
+
Kernel.raise ArgumentError,
|
|
128
|
+
"incremental_from override is not configured for #{view_class.name || view_class.view_key}"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
view_class.send(
|
|
132
|
+
:resolve_source_definition,
|
|
133
|
+
@incremental_source_definition,
|
|
134
|
+
"incremental_from is required for #{view_class.name || view_class.view_key}"
|
|
135
|
+
)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
mixes_in_class_methods ClassMethods
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
module Materialized
|
|
6
|
+
# Raised when a read hits a cold view under the :raise cold-read strategy.
|
|
7
|
+
class NotMaterializedError < StandardError; end
|
|
8
|
+
|
|
9
|
+
# Read and refresh API mixed into a {View}: `rebuild!`, `refresh!`, `refresh_if_stale!`,
|
|
10
|
+
# `materialized?`, `stale?`, `dirty?`, and the routed query methods.
|
|
11
|
+
module ViewQueryAccessClassMethods
|
|
12
|
+
extend T::Sig
|
|
13
|
+
extend T::Helpers
|
|
14
|
+
|
|
15
|
+
sig { params(base: T.class_of(View)).void }
|
|
16
|
+
def self.included(base)
|
|
17
|
+
base.extend(ClassMethods)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# The read and refresh methods available on a {View} subclass.
|
|
21
|
+
module ClassMethods
|
|
22
|
+
extend T::Sig
|
|
23
|
+
|
|
24
|
+
sig { returns(T.class_of(View)) }
|
|
25
|
+
def view_class
|
|
26
|
+
T.cast(self, T.class_of(View))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
sig { returns(T::Boolean) }
|
|
30
|
+
def stale?
|
|
31
|
+
view_class.metadata.stale?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
sig { returns(T::Boolean) }
|
|
35
|
+
def dirty?
|
|
36
|
+
view_class.metadata.dirty?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
sig { returns(T::Boolean) }
|
|
40
|
+
def warm?
|
|
41
|
+
view_class.metadata.warm?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Reads are served from the cache only once warmed and the table exists;
|
|
45
|
+
# otherwise they fall through to the cold-read path.
|
|
46
|
+
sig { returns(T::Boolean) }
|
|
47
|
+
def materialized?
|
|
48
|
+
view_class.table_exists? && view_class.metadata.warm?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
sig { returns(T.nilable(Timestamp)) }
|
|
52
|
+
def last_refreshed_at
|
|
53
|
+
view_class.metadata.last_refreshed_at
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
sig { returns(T::Boolean) }
|
|
57
|
+
def refreshing?
|
|
58
|
+
view_class.metadata.refreshing?
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
sig { void }
|
|
62
|
+
def mark_dependencies_changed!
|
|
63
|
+
view_class.metadata.mark_dirty!
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
sig { returns(T::Boolean) }
|
|
67
|
+
def table_exists?
|
|
68
|
+
view_class.connection.data_source_exists?(view_class.table_name)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Incremental maintenance only — never scans all base data.
|
|
72
|
+
sig { returns(RefreshResult) }
|
|
73
|
+
def refresh!
|
|
74
|
+
Refresher.new(view_class).refresh!
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
sig { returns(T.nilable(RefreshResult)) }
|
|
78
|
+
def refresh_if_stale!
|
|
79
|
+
refresh! if materialized? && stale?
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The only path that scans all base data; `confirm:` guards against
|
|
83
|
+
# firing a full materialization by accident.
|
|
84
|
+
sig { params(confirm: T::Boolean).returns(RefreshResult) }
|
|
85
|
+
def rebuild!(confirm: false)
|
|
86
|
+
unless confirm
|
|
87
|
+
Kernel.raise ArgumentError,
|
|
88
|
+
"#{view_class.name}.rebuild! performs a full materialization; call rebuild!(confirm: true)"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
Refresher.new(view_class).rebuild!
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
sig { params(args: T.untyped).returns(T.untyped) }
|
|
95
|
+
def all(*args)
|
|
96
|
+
read_scope.all(*args)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
sig { params(args: T.untyped).returns(T.untyped) }
|
|
100
|
+
def where(*args)
|
|
101
|
+
partition_scope(args).where(*args)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
sig { params(args: T.untyped).returns(T.untyped) }
|
|
105
|
+
def find(*args)
|
|
106
|
+
read_scope.find(*args)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
sig { params(args: T.untyped).returns(T.untyped) }
|
|
110
|
+
def find_by(*args)
|
|
111
|
+
partition_scope(args).find_by(*args)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
sig { params(args: T.untyped).returns(T.untyped) }
|
|
115
|
+
def count(*args)
|
|
116
|
+
read_scope.count(*args)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
sig { returns(T.untyped) }
|
|
122
|
+
def read_scope
|
|
123
|
+
materialized? ? cache_scope : cold_scope
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Per-partition fast path for keyed reads: serve fresh partitions from the
|
|
127
|
+
# cache, otherwise read through and enqueue maintenance (populate-on-read).
|
|
128
|
+
sig { params(args: T::Array[T.untyped]).returns(T.untyped) }
|
|
129
|
+
def partition_scope(args)
|
|
130
|
+
return cache_scope if materialized?
|
|
131
|
+
|
|
132
|
+
keys = PartitionState.keys_from(view_class, args)
|
|
133
|
+
return cold_scope if keys.nil?
|
|
134
|
+
return cache_scope if PartitionState.new(view_class).all_fresh?(keys)
|
|
135
|
+
|
|
136
|
+
enqueue_partition_maintenance(keys)
|
|
137
|
+
cold_scope
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
sig { returns(T.untyped) }
|
|
141
|
+
def cache_scope
|
|
142
|
+
T.unsafe(view_class).unscoped
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
sig { returns(T.untyped) }
|
|
146
|
+
def cold_scope
|
|
147
|
+
ColdRead.new(view_class).scope
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
sig { params(keys: T::Array[T.untyped]).void }
|
|
151
|
+
def enqueue_partition_maintenance(keys)
|
|
152
|
+
MaintenanceStore.new(view_class).merge!(MaintenanceDelta.scoped(keys))
|
|
153
|
+
RefreshScheduler.schedule(view_class)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
mixes_in_class_methods ClassMethods
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
module Materialized
|
|
6
|
+
# Refresh-policy DSL mixed into a {View}: `refresh_on_change`, `refresh_debounce`, `cold_read`,
|
|
7
|
+
# `warm_up`, `max_staleness`, plus `warm_up!`.
|
|
8
|
+
module ViewRefreshPolicyClassMethods
|
|
9
|
+
extend T::Sig
|
|
10
|
+
extend T::Helpers
|
|
11
|
+
|
|
12
|
+
sig { params(base: T.class_of(View)).void }
|
|
13
|
+
def self.included(base)
|
|
14
|
+
base.extend(ClassMethods)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# The refresh-policy DSL methods available on a {View} subclass.
|
|
18
|
+
module ClassMethods
|
|
19
|
+
extend T::Sig
|
|
20
|
+
|
|
21
|
+
sig { returns(T.class_of(View)) }
|
|
22
|
+
def view_class
|
|
23
|
+
T.cast(self, T.class_of(View))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
sig { params(strategy: Symbol).void }
|
|
27
|
+
def refresh_on_change(strategy = :async)
|
|
28
|
+
@refresh_strategy = T.let(strategy.to_sym, T.nilable(Symbol))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
sig { params(seconds: DebounceInterval).void }
|
|
32
|
+
def refresh_debounce(seconds)
|
|
33
|
+
@refresh_debounce = T.let(seconds, T.nilable(DebounceInterval))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
sig { params(strategy: Symbol).void }
|
|
37
|
+
def cold_read(strategy)
|
|
38
|
+
@cold_read_strategy = T.let(strategy.to_sym, T.nilable(Symbol))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
sig { returns(Symbol) }
|
|
42
|
+
def resolved_cold_read_strategy
|
|
43
|
+
T.let(@cold_read_strategy, T.nilable(Symbol)) ||
|
|
44
|
+
ActiveRecord::Materialized.configuration.default_cold_read_strategy
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Queries warm_up! runs to materialize a cold view's hot partitions, e.g.:
|
|
48
|
+
# warm_up { [where(region: "us"), order(revenue: :desc).limit(50)] }
|
|
49
|
+
sig { params(block: T.proc.returns(T.untyped)).void }
|
|
50
|
+
def warm_up(&block)
|
|
51
|
+
@warm_up_definition = T.let(block, T.nilable(Proc))
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
sig { returns(T::Array[::ActiveRecord::Relation]) }
|
|
55
|
+
def resolved_warm_up_queries
|
|
56
|
+
block = T.let(@warm_up_definition, T.nilable(Proc))
|
|
57
|
+
return [] if block.nil?
|
|
58
|
+
|
|
59
|
+
Kernel.Array(T.unsafe(view_class).instance_eval(&block))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Running each warm_up query enqueues scoped maintenance for the
|
|
63
|
+
# partitions it touches; refresh! then applies it.
|
|
64
|
+
sig { returns(T.nilable(RefreshResult)) }
|
|
65
|
+
def warm_up!
|
|
66
|
+
resolved_warm_up_queries.each(&:to_a)
|
|
67
|
+
view_class.refresh!
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
sig { returns(Symbol) }
|
|
71
|
+
def resolved_refresh_strategy
|
|
72
|
+
@refresh_strategy || ActiveRecord::Materialized.configuration.default_refresh_strategy
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
sig { returns(T.any(Integer, Float)) }
|
|
76
|
+
def resolved_refresh_debounce
|
|
77
|
+
interval = if @refresh_debounce.nil?
|
|
78
|
+
ActiveRecord::Materialized.configuration.default_refresh_debounce
|
|
79
|
+
else
|
|
80
|
+
@refresh_debounce
|
|
81
|
+
end
|
|
82
|
+
interval.respond_to?(:to_f) ? interval.to_f : interval.to_i
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
sig do
|
|
86
|
+
params(
|
|
87
|
+
duration: T.nilable(StalenessDuration),
|
|
88
|
+
block: T.nilable(T.proc.returns(StalenessDuration))
|
|
89
|
+
).void
|
|
90
|
+
end
|
|
91
|
+
def max_staleness(duration = nil, &block)
|
|
92
|
+
@max_staleness_setting = T.let(duration || block, T.nilable(T.any(StalenessDuration, Proc)))
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
sig { returns(T.nilable(StalenessDuration)) }
|
|
96
|
+
def resolved_max_staleness
|
|
97
|
+
setting = @max_staleness_setting
|
|
98
|
+
default = ActiveRecord::Materialized.configuration.default_max_staleness
|
|
99
|
+
return T.cast(default, T.nilable(StalenessDuration)) if setting.nil?
|
|
100
|
+
return T.unsafe(view_class).instance_eval(&setting) if setting.is_a?(Proc)
|
|
101
|
+
|
|
102
|
+
setting
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
mixes_in_class_methods ClassMethods
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|