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,82 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
# Application-level materialized views for Rails/ActiveRecord on databases
|
|
6
|
+
# without native materialized-view support (MySQL, MariaDB, SQLite).
|
|
7
|
+
#
|
|
8
|
+
# Define views by subclassing {Materialized::View}. This module is the
|
|
9
|
+
# top-level entry point for global {configure configuration} and operational
|
|
10
|
+
# helpers such as {verify_schema!}.
|
|
11
|
+
#
|
|
12
|
+
# @see Materialized::View defining a view
|
|
13
|
+
# @see Materialized::Configuration the configurable settings
|
|
14
|
+
module Materialized
|
|
15
|
+
class << self
|
|
16
|
+
extend T::Sig
|
|
17
|
+
|
|
18
|
+
@configuration = T.let(nil, T.nilable(Configuration))
|
|
19
|
+
|
|
20
|
+
# The global configuration object. Prefer {configure} for setting values.
|
|
21
|
+
#
|
|
22
|
+
# @return [Configuration] the current configuration (created on first use)
|
|
23
|
+
sig { returns(Configuration) }
|
|
24
|
+
def configuration
|
|
25
|
+
config = @configuration
|
|
26
|
+
if config.nil?
|
|
27
|
+
config = Configuration.new
|
|
28
|
+
@configuration = T.let(config, T.nilable(Configuration))
|
|
29
|
+
end
|
|
30
|
+
config
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Configure the gem, typically from an initializer.
|
|
34
|
+
#
|
|
35
|
+
# @example config/initializers/activerecord_materialized.rb
|
|
36
|
+
# ActiveRecord::Materialized.configure do |config|
|
|
37
|
+
# config.default_refresh_strategy = :async
|
|
38
|
+
# config.refresh_dispatcher = :active_job
|
|
39
|
+
# config.default_max_staleness = 12.hours
|
|
40
|
+
# end
|
|
41
|
+
#
|
|
42
|
+
# @yieldparam config [Configuration]
|
|
43
|
+
# @return [void]
|
|
44
|
+
sig { params(block: T.proc.params(config: Configuration).void).void }
|
|
45
|
+
def configure(&block)
|
|
46
|
+
yield(configuration)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
sig { returns(String) }
|
|
50
|
+
def metadata_table_name
|
|
51
|
+
configuration.metadata_table_name
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
sig { returns(String) }
|
|
55
|
+
def partition_table_name
|
|
56
|
+
configuration.partition_table_name
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Verifies every registered view's cache table still matches the columns its
|
|
60
|
+
# source relation projects — run it at boot or in CI to catch a view whose
|
|
61
|
+
# definition changed without a migration. Never alters tables.
|
|
62
|
+
#
|
|
63
|
+
# @raise [SchemaVerifier::SchemaDriftError] on the first drifted view
|
|
64
|
+
# @return [void]
|
|
65
|
+
sig { void }
|
|
66
|
+
def verify_schema!
|
|
67
|
+
registered = Registry.all
|
|
68
|
+
registered.each { |view_class| SchemaVerifier.new(view_class).verify! }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
sig { returns(T::Boolean) }
|
|
72
|
+
def atomic_swap_refresh?
|
|
73
|
+
configuration.atomic_swap_refresh
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
sig { params(value: Configuration).void }
|
|
77
|
+
def configuration=(value)
|
|
78
|
+
@configuration = T.let(value, T.nilable(Configuration))
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
module Materialized
|
|
6
|
+
# One row per fresh partition of a cold view; presence means the partition is
|
|
7
|
+
# materialized and current, absence means it is not.
|
|
8
|
+
class PartitionRecord < ::ActiveRecord::Base
|
|
9
|
+
extend T::Sig
|
|
10
|
+
|
|
11
|
+
@table_name_override = T.let(nil, T.nilable(String))
|
|
12
|
+
|
|
13
|
+
self.table_name = ::ActiveRecord::Materialized.partition_table_name
|
|
14
|
+
|
|
15
|
+
sig { params(name: String).void }
|
|
16
|
+
def self.table_name=(name)
|
|
17
|
+
@table_name_override = name
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
sig { returns(String) }
|
|
21
|
+
def self.table_name
|
|
22
|
+
override = @table_name_override
|
|
23
|
+
override.nil? ? ::ActiveRecord::Materialized.partition_table_name : override
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
module Materialized
|
|
6
|
+
# Tracks which partitions of a cold view have been materialized ("fresh") so
|
|
7
|
+
# a read can decide whether a partition is served from the cache or read
|
|
8
|
+
# through to the source. Warm views are fully materialized and ignore this.
|
|
9
|
+
class PartitionState
|
|
10
|
+
extend T::Sig
|
|
11
|
+
|
|
12
|
+
KeyTuple = T.type_alias { T::Array[T.untyped] }
|
|
13
|
+
|
|
14
|
+
sig { params(view_class: ViewClass).void }
|
|
15
|
+
def initialize(view_class)
|
|
16
|
+
@view_class = view_class
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
sig { params(key_tuples: T::Array[KeyTuple]).returns(T::Boolean) }
|
|
20
|
+
def all_fresh?(key_tuples)
|
|
21
|
+
return false if key_tuples.empty?
|
|
22
|
+
|
|
23
|
+
ensure_table!
|
|
24
|
+
serialized = key_tuples.map { |tuple| serialize(tuple) }.uniq
|
|
25
|
+
scope.where(partition_key: serialized).count == serialized.size
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
sig { params(key_tuples: T::Array[KeyTuple]).void }
|
|
29
|
+
def mark_fresh!(key_tuples)
|
|
30
|
+
return if key_tuples.empty?
|
|
31
|
+
|
|
32
|
+
ensure_table!
|
|
33
|
+
key_tuples.uniq.each do |tuple|
|
|
34
|
+
PartitionRecord.create_or_find_by(view_name: view_key, partition_key: serialize(tuple))
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
sig { params(key_tuples: T::Array[KeyTuple]).void }
|
|
39
|
+
def mark_stale!(key_tuples)
|
|
40
|
+
return if key_tuples.empty?
|
|
41
|
+
|
|
42
|
+
ensure_table!
|
|
43
|
+
scope.where(partition_key: key_tuples.map { |tuple| serialize(tuple) }).delete_all
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
sig { void }
|
|
47
|
+
def reset!
|
|
48
|
+
ensure_table!
|
|
49
|
+
scope.delete_all
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The partition key tuples a query touches, or nil unless the conditions are
|
|
53
|
+
# an exact match on the GROUP BY columns (the only case the fast path serves).
|
|
54
|
+
sig { params(view_class: ViewClass, args: T::Array[T.untyped]).returns(T.nilable(T::Array[KeyTuple])) }
|
|
55
|
+
def self.keys_from(view_class, args)
|
|
56
|
+
conditions = single_hash(args)
|
|
57
|
+
return nil if conditions.nil?
|
|
58
|
+
|
|
59
|
+
group_keys = view_class.maintenance_key_columns
|
|
60
|
+
return nil if group_keys.empty?
|
|
61
|
+
|
|
62
|
+
value_lists = key_value_lists(conditions, group_keys)
|
|
63
|
+
value_lists.nil? ? nil : cartesian(value_lists)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
sig { params(args: T::Array[T.untyped]).returns(T.nilable(T::Hash[T.untyped, T.untyped])) }
|
|
67
|
+
def self.single_hash(args)
|
|
68
|
+
return nil unless args.length == 1
|
|
69
|
+
|
|
70
|
+
conditions = args.fetch(0)
|
|
71
|
+
conditions.is_a?(Hash) ? conditions : nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
sig do
|
|
75
|
+
params(conditions: T::Hash[T.untyped, T.untyped], group_keys: T::Array[String])
|
|
76
|
+
.returns(T.nilable(T::Array[T::Array[String]]))
|
|
77
|
+
end
|
|
78
|
+
def self.key_value_lists(conditions, group_keys)
|
|
79
|
+
normalized = conditions.transform_keys(&:to_s)
|
|
80
|
+
return nil unless normalized.keys.sort == group_keys.sort
|
|
81
|
+
|
|
82
|
+
group_keys.map { |column| Array(normalized.fetch(column)).map(&:to_s) }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
sig { params(value_lists: T::Array[T::Array[String]]).returns(T.nilable(T::Array[KeyTuple])) }
|
|
86
|
+
def self.cartesian(value_lists)
|
|
87
|
+
return nil if value_lists.any?(&:empty?)
|
|
88
|
+
|
|
89
|
+
value_lists.reduce([[]]) do |tuples, values|
|
|
90
|
+
tuples.flat_map { |tuple| values.map { |value| tuple + [value] } }
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
sig { returns(String) }
|
|
97
|
+
def view_key
|
|
98
|
+
@view_class.view_key
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
sig { params(key_tuple: KeyTuple).returns(String) }
|
|
102
|
+
def serialize(key_tuple)
|
|
103
|
+
key_tuple.map(&:to_s).to_json
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
sig { returns(::ActiveRecord::Relation) }
|
|
107
|
+
def scope
|
|
108
|
+
PartitionRecord.where(view_name: view_key)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
sig { void }
|
|
112
|
+
def ensure_table!
|
|
113
|
+
connection = @view_class.connection
|
|
114
|
+
return if PartitionRecord.table_exists?
|
|
115
|
+
|
|
116
|
+
table = ::ActiveRecord::Materialized.partition_table_name
|
|
117
|
+
connection.create_table(table) do |t|
|
|
118
|
+
t.string :view_name, null: false
|
|
119
|
+
t.string :partition_key, null: false
|
|
120
|
+
t.datetime :created_at, null: false
|
|
121
|
+
end
|
|
122
|
+
connection.add_index(table, %i[view_name partition_key], unique: true)
|
|
123
|
+
PartitionRecord.reset_column_information
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
module Materialized
|
|
6
|
+
# Portable, aliased Arel aggregate helpers for building a view's
|
|
7
|
+
# {ViewConfigurationClassMethods::ClassMethods#materialized_from source
|
|
8
|
+
# relation} without raw SQL. `extend` it into a view (or any object) so the
|
|
9
|
+
# helpers are available where you build the relation.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# class SalesByCategory < ActiveRecord::Materialized::View
|
|
13
|
+
# extend ActiveRecord::Materialized::QueryExpressions
|
|
14
|
+
# materialized_from do
|
|
15
|
+
# items = Item.arel_table
|
|
16
|
+
# Item.group(:category).select(
|
|
17
|
+
# items[:category],
|
|
18
|
+
# sum_as(items[:amount], as: :revenue),
|
|
19
|
+
# count_all_as(as: :order_count)
|
|
20
|
+
# )
|
|
21
|
+
# end
|
|
22
|
+
# end
|
|
23
|
+
module QueryExpressions
|
|
24
|
+
extend T::Sig
|
|
25
|
+
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
# @return [Arel::Nodes::As] +SUM(attribute) AS <as>+
|
|
29
|
+
sig { params(attribute: Arel::Attributes::Attribute, as: T.any(Symbol, String)).returns(Arel::Nodes::As) }
|
|
30
|
+
def sum_as(attribute, as:)
|
|
31
|
+
attribute.sum.as(as.to_s)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @return [Arel::Nodes::As] +AVG(attribute) AS <as>+
|
|
35
|
+
sig { params(attribute: Arel::Attributes::Attribute, as: T.any(Symbol, String)).returns(Arel::Nodes::As) }
|
|
36
|
+
def avg_as(attribute, as:)
|
|
37
|
+
attribute.average.as(as.to_s)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @return [Arel::Nodes::As] +MIN(attribute) AS <as>+
|
|
41
|
+
sig { params(attribute: Arel::Attributes::Attribute, as: T.any(Symbol, String)).returns(Arel::Nodes::As) }
|
|
42
|
+
def min_as(attribute, as:)
|
|
43
|
+
attribute.minimum.as(as.to_s)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @return [Arel::Nodes::As] +MAX(attribute) AS <as>+
|
|
47
|
+
sig { params(attribute: Arel::Attributes::Attribute, as: T.any(Symbol, String)).returns(Arel::Nodes::As) }
|
|
48
|
+
def max_as(attribute, as:)
|
|
49
|
+
attribute.maximum.as(as.to_s)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @return [Arel::Nodes::As] +COUNT(*) AS <as>+ — a trustworthy per-partition row count
|
|
53
|
+
sig { params(as: T.any(Symbol, String)).returns(Arel::Nodes::As) }
|
|
54
|
+
def count_all_as(as:)
|
|
55
|
+
Arel.star.count.as(as.to_s)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# @return [Arel::Nodes::As] +COUNT(attribute) AS <as>+ (non-null values)
|
|
59
|
+
sig { params(attribute: Arel::Attributes::Attribute, as: T.any(Symbol, String)).returns(Arel::Nodes::As) }
|
|
60
|
+
def count_as(attribute, as:)
|
|
61
|
+
attribute.count.as(as.to_s)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @return [Arel::Nodes::As] +COUNT(DISTINCT attribute) AS <as>+
|
|
65
|
+
sig { params(attribute: Arel::Attributes::Attribute, as: T.any(Symbol, String)).returns(Arel::Nodes::As) }
|
|
66
|
+
def count_distinct_as(attribute, as:)
|
|
67
|
+
attribute.count(true).as(as.to_s)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# @return [Arel::Nodes::NamedFunction] +LENGTH(attribute)+
|
|
71
|
+
sig { params(attribute: Arel::Attributes::Attribute).returns(Arel::Nodes::NamedFunction) }
|
|
72
|
+
def length(attribute)
|
|
73
|
+
Arel::Nodes::NamedFunction.new("LENGTH", [attribute])
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @return [Arel::Nodes::As] +SUM(LENGTH(attribute)) AS <as>+
|
|
77
|
+
sig { params(attribute: Arel::Attributes::Attribute, as: T.any(Symbol, String)).returns(Arel::Nodes::As) }
|
|
78
|
+
def sum_length_as(attribute, as:)
|
|
79
|
+
length(attribute).sum.as(as.to_s)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
module Materialized
|
|
6
|
+
# Rails integration: wires the gem's load hooks and rake tasks into a host application.
|
|
7
|
+
class Railtie < ::Rails::Railtie
|
|
8
|
+
extend T::Sig
|
|
9
|
+
|
|
10
|
+
rake_tasks do
|
|
11
|
+
require_relative "tasks"
|
|
12
|
+
Tasks.define!
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
module Materialized
|
|
6
|
+
# Adds `before_refresh` / `after_refresh` lifecycle callbacks to a {View}.
|
|
7
|
+
module RefreshCallbacks
|
|
8
|
+
extend T::Sig
|
|
9
|
+
|
|
10
|
+
sig { params(base: T.class_of(View)).void }
|
|
11
|
+
def self.included(base)
|
|
12
|
+
base.extend(ClassMethods)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# The callback-registration methods available on a {View} subclass.
|
|
16
|
+
module ClassMethods
|
|
17
|
+
extend T::Sig
|
|
18
|
+
|
|
19
|
+
sig { returns(T::Hash[Symbol, T::Array[RefreshCallbackName]]) }
|
|
20
|
+
def refresh_callback_store
|
|
21
|
+
@refresh_callback_store ||= T.let(
|
|
22
|
+
{ before_refresh: [], after_refresh: [] },
|
|
23
|
+
T.nilable(T::Hash[Symbol, T::Array[RefreshCallbackName]])
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
sig { params(methods: Symbol, block: T.nilable(T.proc.void)).void }
|
|
28
|
+
def before_refresh(*methods, &block)
|
|
29
|
+
register_refresh_callback(:before_refresh, methods, block)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
sig { params(methods: Symbol, block: T.nilable(T.proc.void)).void }
|
|
33
|
+
def after_refresh(*methods, &block)
|
|
34
|
+
register_refresh_callback(:after_refresh, methods, block)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
sig { params(name: Symbol).void }
|
|
38
|
+
def run_refresh_callbacks(name)
|
|
39
|
+
callbacks = refresh_callback_store.fetch(name, [])
|
|
40
|
+
callbacks.each do |callback|
|
|
41
|
+
case callback
|
|
42
|
+
when Symbol
|
|
43
|
+
T.unsafe(self).public_send(callback)
|
|
44
|
+
when Proc
|
|
45
|
+
T.unsafe(self).instance_eval(&callback)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
sig { params(name: Symbol, methods: T::Array[Symbol], block: T.nilable(T.proc.void)).void }
|
|
53
|
+
def register_refresh_callback(name, methods, block)
|
|
54
|
+
callbacks = T.must(refresh_callback_store[name]).dup
|
|
55
|
+
methods.each { |method| callbacks << method }
|
|
56
|
+
callbacks << block if block
|
|
57
|
+
refresh_callback_store[name] = callbacks
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
module Materialized
|
|
6
|
+
# ActiveJob wrapper that runs a view's incremental refresh on a background worker.
|
|
7
|
+
class RefreshJob < ::ActiveJob::Base
|
|
8
|
+
extend T::Sig
|
|
9
|
+
|
|
10
|
+
queue_as { ::ActiveRecord::Materialized.configuration.refresh_queue_name }
|
|
11
|
+
|
|
12
|
+
sig { params(view_key: String).void }
|
|
13
|
+
def perform(view_key)
|
|
14
|
+
view_class = Registry.find(view_key)
|
|
15
|
+
return if view_class.nil?
|
|
16
|
+
return unless view_class.dirty?
|
|
17
|
+
|
|
18
|
+
view_class.refresh!
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
module Materialized
|
|
6
|
+
# The outcome of a refresh or rebuild, returned by
|
|
7
|
+
# {ViewQueryAccessClassMethods::ClassMethods#refresh! refresh!},
|
|
8
|
+
# {ViewQueryAccessClassMethods::ClassMethods#rebuild! rebuild!}, and
|
|
9
|
+
# {ViewQueryAccessClassMethods::ClassMethods#refresh_if_stale! refresh_if_stale!}.
|
|
10
|
+
#
|
|
11
|
+
# @!attribute [r] view_class
|
|
12
|
+
# @return [Class] the view that was refreshed
|
|
13
|
+
# @!attribute [r] row_count
|
|
14
|
+
# @return [Integer] rows in the cache table after the operation
|
|
15
|
+
# @!attribute [r] duration_ms
|
|
16
|
+
# @return [Integer] wall-clock duration in milliseconds
|
|
17
|
+
# @!attribute [r] refreshed_at
|
|
18
|
+
# @return [Time, nil] when the refresh completed (nil when skipped)
|
|
19
|
+
# @!attribute [r] skipped
|
|
20
|
+
# @return [Boolean] true when there was nothing to do (e.g. an unmaintainable view)
|
|
21
|
+
class RefreshResult < T::Struct
|
|
22
|
+
extend T::Sig
|
|
23
|
+
|
|
24
|
+
const :view_class, T.class_of(View)
|
|
25
|
+
const :row_count, Integer
|
|
26
|
+
const :duration_ms, Integer
|
|
27
|
+
const :refreshed_at, T.nilable(Timestamp)
|
|
28
|
+
const :skipped, T::Boolean, default: false
|
|
29
|
+
|
|
30
|
+
# A no-op result, returned when refresh! was requested on a view that is
|
|
31
|
+
# not maintainable.
|
|
32
|
+
#
|
|
33
|
+
# @return [RefreshResult]
|
|
34
|
+
sig { params(view_class: T.class_of(View)).returns(RefreshResult) }
|
|
35
|
+
def self.skipped(view_class)
|
|
36
|
+
new(view_class: view_class, row_count: 0, duration_ms: 0, refreshed_at: nil, skipped: true)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
module Materialized
|
|
6
|
+
# Dispatches a view's configured refresh strategy (`:async` / `:immediate` / `:manual`) after a write.
|
|
7
|
+
#
|
|
8
|
+
# @api private
|
|
9
|
+
class RefreshScheduler
|
|
10
|
+
class << self
|
|
11
|
+
extend T::Sig
|
|
12
|
+
|
|
13
|
+
sig { params(view_class: ViewClass).void }
|
|
14
|
+
def schedule(view_class)
|
|
15
|
+
# Capture the transition before marking dirty so the async dispatcher
|
|
16
|
+
# can coalesce: a bulk write only needs one job for the whole burst.
|
|
17
|
+
newly_dirty = !view_class.dirty?
|
|
18
|
+
view_class.mark_dependencies_changed!
|
|
19
|
+
|
|
20
|
+
case view_class.resolved_refresh_strategy
|
|
21
|
+
when :manual
|
|
22
|
+
nil
|
|
23
|
+
when :immediate
|
|
24
|
+
view_class.refresh!
|
|
25
|
+
when :async
|
|
26
|
+
dispatch_async(view_class, newly_dirty)
|
|
27
|
+
else
|
|
28
|
+
raise ArgumentError, "Unknown refresh strategy: #{view_class.resolved_refresh_strategy}"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
sig { params(view_class: ViewClass, newly_dirty: T::Boolean).void }
|
|
35
|
+
def dispatch_async(view_class, newly_dirty)
|
|
36
|
+
if use_active_job?
|
|
37
|
+
# ActiveJob has no enqueue-level coalescing, so only enqueue when the
|
|
38
|
+
# view first goes dirty; the job drains the accumulated payload. The
|
|
39
|
+
# in-process refresher already coalesces via its debounce timer.
|
|
40
|
+
T.unsafe(RefreshJob).perform_later(view_class.view_key) if newly_dirty
|
|
41
|
+
else
|
|
42
|
+
AsyncRefresher.enqueue(view_class)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
sig { returns(T::Boolean) }
|
|
47
|
+
def use_active_job?
|
|
48
|
+
config = ActiveRecord::Materialized.configuration
|
|
49
|
+
!!(config.refresh_dispatcher == :active_job && defined?(ActiveJob::Base))
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
module Materialized
|
|
6
|
+
# Orchestrates explicit rebuilds and incremental maintenance for a single view.
|
|
7
|
+
#
|
|
8
|
+
# @api private
|
|
9
|
+
class Refresher
|
|
10
|
+
extend T::Sig
|
|
11
|
+
|
|
12
|
+
# Raised when a refresh or rebuild fails.
|
|
13
|
+
class RefreshError < StandardError; end
|
|
14
|
+
|
|
15
|
+
sig { returns(ViewClass) }
|
|
16
|
+
attr_reader :view_class
|
|
17
|
+
|
|
18
|
+
sig { params(view_class: ViewClass).void }
|
|
19
|
+
def initialize(view_class)
|
|
20
|
+
@view_class = view_class
|
|
21
|
+
@metadata = T.let(nil, T.nilable(Metadata))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Full materialization — the only path that scans all base data.
|
|
25
|
+
sig { returns(RefreshResult) }
|
|
26
|
+
def rebuild!
|
|
27
|
+
run_cycle(-> { perform_rebuild! })
|
|
28
|
+
rescue StandardError => e
|
|
29
|
+
fail_refresh!(e)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Incremental maintenance only; a no-op when the view is not maintainable.
|
|
33
|
+
sig { returns(RefreshResult) }
|
|
34
|
+
def refresh!
|
|
35
|
+
return RefreshResult.skipped(view_class) unless maintainable?
|
|
36
|
+
|
|
37
|
+
run_cycle(-> { incremental_refresh! })
|
|
38
|
+
rescue StandardError => e
|
|
39
|
+
fail_refresh!(e)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
sig { returns(T::Boolean) }
|
|
45
|
+
def maintainable?
|
|
46
|
+
return false unless view_class.incrementally_maintainable?
|
|
47
|
+
|
|
48
|
+
pending = MaintenanceStore.new(view_class).pending
|
|
49
|
+
return false if pending.nil?
|
|
50
|
+
|
|
51
|
+
# Never full-populate a cold view from maintenance — reads fall through
|
|
52
|
+
# to the source instead. Scoped deltas populate just their partitions.
|
|
53
|
+
return false if !view_class.materialized? && pending.is_a?(MaintenanceDelta) && pending.full_partition?
|
|
54
|
+
|
|
55
|
+
true
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
sig { params(operation: T.proc.returns(Integer)).returns(RefreshResult) }
|
|
59
|
+
def run_cycle(operation)
|
|
60
|
+
raise RefreshError, "#{view_class.name} is already refreshing" if metadata.refreshing?
|
|
61
|
+
|
|
62
|
+
started_at = monotonic_clock
|
|
63
|
+
metadata.mark_refreshing!
|
|
64
|
+
view_class.run_refresh_callbacks(:before_refresh)
|
|
65
|
+
|
|
66
|
+
row_count = operation.call
|
|
67
|
+
result = complete_refresh!(row_count: row_count, duration_ms: elapsed_milliseconds(started_at))
|
|
68
|
+
view_class.run_refresh_callbacks(:after_refresh)
|
|
69
|
+
result
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
sig { returns(Integer) }
|
|
73
|
+
def perform_rebuild!
|
|
74
|
+
row_count = RelationCacheWriter.new(view_class).atomic_swap!(view_class.resolved_source)
|
|
75
|
+
metadata.mark_warm!
|
|
76
|
+
# Fully materialized now, so the cold-view partition exceptions no longer apply.
|
|
77
|
+
PartitionState.new(view_class).reset!
|
|
78
|
+
row_count
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
sig { returns(Integer) }
|
|
82
|
+
def incremental_refresh!
|
|
83
|
+
ensure_cache_table!
|
|
84
|
+
|
|
85
|
+
store = MaintenanceStore.new(view_class)
|
|
86
|
+
pending = store.pending
|
|
87
|
+
return apply_summary_delta!(store, pending) if pending.is_a?(SummaryDelta)
|
|
88
|
+
|
|
89
|
+
IncrementalMaintainer.new(view_class).maintain!(view_class.connection, view_class.table_name)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Cheap DDL so partition maintenance has somewhere to write — never a populate.
|
|
93
|
+
sig { void }
|
|
94
|
+
def ensure_cache_table!
|
|
95
|
+
return if view_class.table_exists?
|
|
96
|
+
|
|
97
|
+
CacheTableSchema.ensure_table!(view_class, view_class.resolved_source)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
sig { params(store: MaintenanceStore, summary: SummaryDelta).returns(Integer) }
|
|
101
|
+
def apply_summary_delta!(store, summary)
|
|
102
|
+
store.clear!
|
|
103
|
+
DeltaMaintainer.new(view_class).apply!(summary)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
sig { params(error: StandardError).returns(T.noreturn) }
|
|
107
|
+
def fail_refresh!(error)
|
|
108
|
+
metadata.mark_failed!(error)
|
|
109
|
+
raise RefreshError, "Failed to refresh #{view_class.name}: #{error.message}", error.backtrace
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
sig { returns(Metadata) }
|
|
113
|
+
def metadata
|
|
114
|
+
@metadata ||= view_class.metadata
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
sig { returns(Float) }
|
|
118
|
+
def monotonic_clock
|
|
119
|
+
T.cast(Process.clock_gettime(Process::CLOCK_MONOTONIC), Float)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
sig { params(started_at: Float).returns(Integer) }
|
|
123
|
+
def elapsed_milliseconds(started_at)
|
|
124
|
+
((monotonic_clock - started_at) * 1000).round
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
sig { params(row_count: Integer, duration_ms: Integer).returns(RefreshResult) }
|
|
128
|
+
def complete_refresh!(row_count:, duration_ms:)
|
|
129
|
+
metadata.mark_refreshed!(row_count: row_count, duration_ms: duration_ms)
|
|
130
|
+
RefreshResult.new(
|
|
131
|
+
view_class: view_class,
|
|
132
|
+
row_count: row_count,
|
|
133
|
+
duration_ms: duration_ms,
|
|
134
|
+
refreshed_at: metadata.last_refreshed_at
|
|
135
|
+
)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|