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.
Files changed (59) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +37 -0
  3. data/LICENSE +21 -0
  4. data/README.md +526 -0
  5. data/lib/activerecord/materialized/aggregate_analysis.rb +132 -0
  6. data/lib/activerecord/materialized/async_refresher.rb +105 -0
  7. data/lib/activerecord/materialized/cache_table_schema.rb +67 -0
  8. data/lib/activerecord/materialized/cold_read.rb +60 -0
  9. data/lib/activerecord/materialized/configuration.rb +80 -0
  10. data/lib/activerecord/materialized/delta_maintainer.rb +74 -0
  11. data/lib/activerecord/materialized/dependency_registry.rb +107 -0
  12. data/lib/activerecord/materialized/dependency_trackable.rb +48 -0
  13. data/lib/activerecord/materialized/incremental_maintainer.rb +58 -0
  14. data/lib/activerecord/materialized/maintenance_delta.rb +82 -0
  15. data/lib/activerecord/materialized/maintenance_delta_builder.rb +62 -0
  16. data/lib/activerecord/materialized/maintenance_store.rb +82 -0
  17. data/lib/activerecord/materialized/metadata/maintenance_payload.rb +33 -0
  18. data/lib/activerecord/materialized/metadata/schema.rb +84 -0
  19. data/lib/activerecord/materialized/metadata/timestamps.rb +31 -0
  20. data/lib/activerecord/materialized/metadata.rb +138 -0
  21. data/lib/activerecord/materialized/metadata_record.rb +28 -0
  22. data/lib/activerecord/materialized/migration_builder.rb +38 -0
  23. data/lib/activerecord/materialized/module_api.rb +82 -0
  24. data/lib/activerecord/materialized/partition_record.rb +27 -0
  25. data/lib/activerecord/materialized/partition_state.rb +127 -0
  26. data/lib/activerecord/materialized/query_expressions.rb +83 -0
  27. data/lib/activerecord/materialized/railtie.rb +16 -0
  28. data/lib/activerecord/materialized/refresh_callbacks.rb +62 -0
  29. data/lib/activerecord/materialized/refresh_job.rb +22 -0
  30. data/lib/activerecord/materialized/refresh_result.rb +40 -0
  31. data/lib/activerecord/materialized/refresh_scheduler.rb +54 -0
  32. data/lib/activerecord/materialized/refresher.rb +139 -0
  33. data/lib/activerecord/materialized/registry.rb +74 -0
  34. data/lib/activerecord/materialized/relation_cache_writer.rb +137 -0
  35. data/lib/activerecord/materialized/schema_verifier.rb +64 -0
  36. data/lib/activerecord/materialized/summary_delta.rb +76 -0
  37. data/lib/activerecord/materialized/summary_delta_builder.rb +58 -0
  38. data/lib/activerecord/materialized/table_model_registry.rb +43 -0
  39. data/lib/activerecord/materialized/tasks.rb +79 -0
  40. data/lib/activerecord/materialized/type_reexports.rb +14 -0
  41. data/lib/activerecord/materialized/version.rb +9 -0
  42. data/lib/activerecord/materialized/view.rb +79 -0
  43. data/lib/activerecord/materialized/view_class.rb +8 -0
  44. data/lib/activerecord/materialized/view_configuration_class_methods.rb +103 -0
  45. data/lib/activerecord/materialized/view_definition.rb +133 -0
  46. data/lib/activerecord/materialized/view_incremental_class_methods.rb +142 -0
  47. data/lib/activerecord/materialized/view_query_access_class_methods.rb +160 -0
  48. data/lib/activerecord/materialized/view_refresh_policy_class_methods.rb +109 -0
  49. data/lib/activerecord/materialized/write_change.rb +69 -0
  50. data/lib/activerecord/materialized.rb +55 -0
  51. data/lib/activerecord_materialized_types.rb +18 -0
  52. data/lib/generators/activerecord_materialized/install/templates/README +55 -0
  53. data/lib/generators/activerecord_materialized/install/templates/create_ar_materialized_view_metadata.rb.erb +30 -0
  54. data/lib/generators/activerecord_materialized/install_generator.rb +32 -0
  55. data/lib/generators/activerecord_materialized/migration_generator.rb +51 -0
  56. data/lib/generators/activerecord_materialized/templates/materialized_view.rb.erb +17 -0
  57. data/lib/generators/activerecord_materialized/templates/materialized_view_migration.rb.erb +11 -0
  58. data/lib/generators/activerecord_materialized/view_generator.rb +18 -0
  59. 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