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,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