activerecord-materialized 0.1.0 → 0.2.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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +93 -0
  3. data/README.md +139 -324
  4. data/lib/activerecord/materialized/aggregate_analysis.rb +10 -31
  5. data/lib/activerecord/materialized/async_refresher.rb +18 -27
  6. data/lib/activerecord/materialized/cache_table_schema.rb +53 -34
  7. data/lib/activerecord/materialized/change_source.rb +25 -0
  8. data/lib/activerecord/materialized/cold_read.rb +1 -10
  9. data/lib/activerecord/materialized/column_type_inference.rb +167 -0
  10. data/lib/activerecord/materialized/configurable_table_name.rb +45 -0
  11. data/lib/activerecord/materialized/configuration.rb +104 -29
  12. data/lib/activerecord/materialized/connection_routing.rb +29 -0
  13. data/lib/activerecord/materialized/data_verification_result.rb +41 -0
  14. data/lib/activerecord/materialized/data_verifier.rb +162 -0
  15. data/lib/activerecord/materialized/debezium_envelope.rb +105 -0
  16. data/lib/activerecord/materialized/delta_maintainer.rb +4 -15
  17. data/lib/activerecord/materialized/dependency_registry.rb +43 -35
  18. data/lib/activerecord/materialized/dependency_trackable.rb +7 -14
  19. data/lib/activerecord/materialized/incremental_maintainer.rb +45 -28
  20. data/lib/activerecord/materialized/incremental_refresh.rb +63 -0
  21. data/lib/activerecord/materialized/instrumentation.rb +102 -0
  22. data/lib/activerecord/materialized/maintenance_delta.rb +4 -19
  23. data/lib/activerecord/materialized/maintenance_delta_builder.rb +46 -14
  24. data/lib/activerecord/materialized/maintenance_store.rb +58 -21
  25. data/lib/activerecord/materialized/metadata/maintenance_payload.rb +1 -7
  26. data/lib/activerecord/materialized/metadata/reconciliation.rb +25 -0
  27. data/lib/activerecord/materialized/metadata/schema.rb +19 -41
  28. data/lib/activerecord/materialized/metadata/timestamps.rb +0 -5
  29. data/lib/activerecord/materialized/metadata.rb +41 -25
  30. data/lib/activerecord/materialized/metadata_record.rb +2 -16
  31. data/lib/activerecord/materialized/migration_builder.rb +8 -8
  32. data/lib/activerecord/materialized/module_api.rb +219 -22
  33. data/lib/activerecord/materialized/partition_filter.rb +45 -0
  34. data/lib/activerecord/materialized/partition_keyed_store.rb +72 -0
  35. data/lib/activerecord/materialized/partition_record.rb +2 -16
  36. data/lib/activerecord/materialized/partition_snapshot.rb +79 -0
  37. data/lib/activerecord/materialized/partition_state.rb +47 -46
  38. data/lib/activerecord/materialized/query_expressions.rb +0 -12
  39. data/lib/activerecord/materialized/railtie.rb +15 -3
  40. data/lib/activerecord/materialized/read_router.rb +51 -0
  41. data/lib/activerecord/materialized/reconcile_job.rb +22 -0
  42. data/lib/activerecord/materialized/reconcile_result.rb +36 -0
  43. data/lib/activerecord/materialized/reconciler.rb +79 -0
  44. data/lib/activerecord/materialized/refresh_callbacks.rb +4 -18
  45. data/lib/activerecord/materialized/refresh_job.rb +0 -4
  46. data/lib/activerecord/materialized/refresh_result.rb +2 -10
  47. data/lib/activerecord/materialized/refresh_scheduler.rb +5 -7
  48. data/lib/activerecord/materialized/refresher.rb +35 -50
  49. data/lib/activerecord/materialized/registry.rb +26 -16
  50. data/lib/activerecord/materialized/relation_cache_writer.rb +12 -46
  51. data/lib/activerecord/materialized/schema_verifier.rb +0 -9
  52. data/lib/activerecord/materialized/source_watermark.rb +93 -0
  53. data/lib/activerecord/materialized/source_watermark_record.rb +16 -0
  54. data/lib/activerecord/materialized/summary_delta.rb +1 -17
  55. data/lib/activerecord/materialized/summary_delta_builder.rb +2 -10
  56. data/lib/activerecord/materialized/table_model_registry.rb +1 -8
  57. data/lib/activerecord/materialized/table_swap.rb +106 -0
  58. data/lib/activerecord/materialized/tasks.rb +46 -38
  59. data/lib/activerecord/materialized/version.rb +1 -2
  60. data/lib/activerecord/materialized/view.rb +11 -22
  61. data/lib/activerecord/materialized/view_configuration_class_methods.rb +23 -35
  62. data/lib/activerecord/materialized/view_definition.rb +76 -65
  63. data/lib/activerecord/materialized/view_incremental_class_methods.rb +57 -60
  64. data/lib/activerecord/materialized/view_loader.rb +49 -0
  65. data/lib/activerecord/materialized/view_query_access_class_methods.rb +100 -57
  66. data/lib/activerecord/materialized/view_refresh_policy_class_methods.rb +70 -37
  67. data/lib/activerecord/materialized/write_change.rb +75 -26
  68. data/lib/activerecord/materialized/write_maintenance.rb +73 -0
  69. data/lib/activerecord/materialized/write_outbox.rb +345 -0
  70. data/lib/activerecord/materialized/write_outbox_record.rb +17 -0
  71. data/lib/activerecord/materialized.rb +27 -6
  72. data/lib/generators/activerecord_materialized/install/templates/create_ar_materialized_view_metadata.rb.erb +14 -0
  73. data/lib/generators/activerecord_materialized/install_generator.rb +1 -6
  74. data/lib/generators/activerecord_materialized/migration_generator.rb +3 -9
  75. data/lib/generators/activerecord_materialized/outbox_generator.rb +36 -0
  76. data/lib/generators/activerecord_materialized/templates/materialized_view_migration.rb.erb +5 -1
  77. data/lib/generators/activerecord_materialized/templates/write_outbox_migration.rb.erb +21 -0
  78. data/lib/generators/activerecord_materialized/view_generator.rb +0 -4
  79. metadata +28 -19
  80. data/lib/activerecord/materialized/type_reexports.rb +0 -14
  81. data/lib/activerecord/materialized/view_class.rb +0 -8
  82. data/lib/activerecord_materialized_types.rb +0 -18
@@ -1,4 +1,3 @@
1
- # typed: strict
2
1
  # frozen_string_literal: true
3
2
 
4
3
  module ActiveRecord
@@ -21,60 +20,49 @@ module ActiveRecord
21
20
  # end
22
21
  # end
23
22
  module QueryExpressions
24
- extend T::Sig
25
-
26
23
  module_function
27
24
 
28
25
  # @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
26
  def sum_as(attribute, as:)
31
27
  attribute.sum.as(as.to_s)
32
28
  end
33
29
 
34
30
  # @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
31
  def avg_as(attribute, as:)
37
32
  attribute.average.as(as.to_s)
38
33
  end
39
34
 
40
35
  # @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
36
  def min_as(attribute, as:)
43
37
  attribute.minimum.as(as.to_s)
44
38
  end
45
39
 
46
40
  # @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
41
  def max_as(attribute, as:)
49
42
  attribute.maximum.as(as.to_s)
50
43
  end
51
44
 
52
45
  # @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
46
  def count_all_as(as:)
55
47
  Arel.star.count.as(as.to_s)
56
48
  end
57
49
 
58
50
  # @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
51
  def count_as(attribute, as:)
61
52
  attribute.count.as(as.to_s)
62
53
  end
63
54
 
64
55
  # @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
56
  def count_distinct_as(attribute, as:)
67
57
  attribute.count(true).as(as.to_s)
68
58
  end
69
59
 
70
60
  # @return [Arel::Nodes::NamedFunction] +LENGTH(attribute)+
71
- sig { params(attribute: Arel::Attributes::Attribute).returns(Arel::Nodes::NamedFunction) }
72
61
  def length(attribute)
73
62
  Arel::Nodes::NamedFunction.new("LENGTH", [attribute])
74
63
  end
75
64
 
76
65
  # @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
66
  def sum_length_as(attribute, as:)
79
67
  length(attribute).sum.as(as.to_s)
80
68
  end
@@ -1,16 +1,28 @@
1
- # typed: strict
2
1
  # frozen_string_literal: true
3
2
 
4
3
  module ActiveRecord
5
4
  module Materialized
6
5
  # Rails integration: wires the gem's load hooks and rake tasks into a host application.
7
6
  class Railtie < ::Rails::Railtie
8
- extend T::Sig
9
-
10
7
  rake_tasks do
11
8
  require_relative "tasks"
12
9
  Tasks.define!
13
10
  end
11
+
12
+ # Load view classes on boot and on every dev reload so their depends_on commit callbacks are
13
+ # installed even under Zeitwerk's lazy loading (config.eager_load = false). Without this, a view
14
+ # whose constant nothing has referenced yet is dormant and writes to its dependencies don't
15
+ # schedule maintenance. to_prepare runs after the autoloaders are set up (once in production,
16
+ # on each reload in development), and ViewLoader.load! is idempotent.
17
+ config.to_prepare do
18
+ ActiveRecord::Materialized::ViewLoader.load!
19
+ end
20
+
21
+ # After the host's initializers have run (so config.refresh_dispatcher is final),
22
+ # warn if the in-process refresher is active — it is single-process-only.
23
+ config.after_initialize do
24
+ ActiveRecord::Materialized.warn_if_in_process_dispatcher!
25
+ end
14
26
  end
15
27
  end
16
28
  end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module Materialized
5
+ # Routes a read to the cache table or to the cold-read fallback
6
+ # (populate-on-read for keyed reads), emitting the read instrumentation event
7
+ # for whichever path it serves.
8
+ #
9
+ # @api private
10
+ class ReadRouter
11
+ def initialize(view_class)
12
+ @view_class = view_class
13
+ end
14
+
15
+ # Routing for reads with no partition predicate to exploit.
16
+ def scope
17
+ @view_class.materialized? ? cache : cold
18
+ end
19
+
20
+ # Per-partition fast path for keyed reads: serve fresh partitions from the
21
+ # cache, otherwise read through and enqueue maintenance (populate-on-read).
22
+ def partition_scope(args)
23
+ return cache if @view_class.materialized?
24
+
25
+ keys = PartitionState.keys_from(@view_class, args)
26
+ return cold if keys.nil?
27
+ return cache if PartitionState.new(@view_class).all_fresh?(keys)
28
+
29
+ enqueue_partition_maintenance(keys)
30
+ cold
31
+ end
32
+
33
+ private
34
+
35
+ def cache
36
+ Instrumentation.read(@view_class, source: :cache)
37
+ @view_class.unscoped
38
+ end
39
+
40
+ def cold
41
+ Instrumentation.read(@view_class, source: @view_class.resolved_cold_read_strategy)
42
+ ColdRead.new(@view_class).scope
43
+ end
44
+
45
+ def enqueue_partition_maintenance(keys)
46
+ MaintenanceStore.new(@view_class).merge!(MaintenanceDelta.scoped(keys))
47
+ RefreshScheduler.schedule(@view_class)
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module Materialized
5
+ # ActiveJob wrapper that reconciles a single view on a background worker — the
6
+ # per-view fan-out unit for the periodic bounded-staleness backstop across a
7
+ # fleet, mirroring {RefreshJob} for the write path.
8
+ class ReconcileJob < ::ActiveJob::Base
9
+ queue_as { ::ActiveRecord::Materialized.configuration.reconcile_queue_name }
10
+
11
+ def perform(view_key, mode: :checksum, sample: nil)
12
+ view_class = Registry.find(view_key)
13
+ return if view_class.nil?
14
+ # Another server in the fleet may have refreshed or reconciled it since this
15
+ # job was enqueued; skip the redundant (expensive) verification if so.
16
+ return unless view_class.stale?
17
+
18
+ view_class.reconcile!(mode: mode, sample: sample)
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module Materialized
5
+ # The outcome of a {Reconciler} run: which partitions were found divergent and
6
+ # repaired with scoped maintenance, and whether the run was deferred because a
7
+ # refresh was already in flight (in which case nothing was changed — the next
8
+ # scheduled tick reconciles).
9
+ ReconcileResult = Data.define(
10
+ :view_name,
11
+ :mode,
12
+ # The divergent partition keys reconciliation repaired (missing, extra, or mismatched).
13
+ :repaired_keys,
14
+ # True when a concurrent refresh deferred the run; the queued repair drains later.
15
+ :deferred,
16
+ # Set when a batch run (reconcile_all!/reconcile_stale!) caught an error for this
17
+ # view, so one failing view doesn't abort reconciliation for the rest of the fleet.
18
+ :error
19
+ ) do
20
+ def initialize(view_name:, mode:, repaired_keys:, deferred: false, error: nil) = super
21
+
22
+ def repaired_partition_count
23
+ repaired_keys.size
24
+ end
25
+
26
+ # Repaired real drift this run — divergence was found and applied, not deferred.
27
+ def repaired?
28
+ repaired_keys.any? && !deferred
29
+ end
30
+
31
+ def failed?
32
+ !error.nil?
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module Materialized
5
+ # Bounds a view's staleness in time: verifies its materialized contents against
6
+ # the source (via {DataVerifier}) and repairs whatever the change source missed,
7
+ # using SCOPED maintenance — never a full rebuild. Meant to run on a schedule as a
8
+ # backstop for writes any change source can miss (a bulk/raw write, a dropped or
9
+ # lagging CDC event).
10
+ #
11
+ # Safe alongside normal refresh. It drains pending maintenance first, so remaining
12
+ # drift is genuinely missed writes rather than un-applied maintenance, then repairs
13
+ # through the same guarded, transactional maintenance path. When a refresh is
14
+ # already in flight it defers to the next tick rather than verifying a cache
15
+ # mid-mutation or racing that cycle's payload — so it can neither corrupt the cache
16
+ # nor double-maintain.
17
+ #
18
+ # @api private
19
+ class Reconciler
20
+ def initialize(view_class, mode: :checksum, sample: nil)
21
+ @view_class = view_class
22
+ @mode = mode
23
+ @sample = sample
24
+ end
25
+
26
+ def reconcile!
27
+ # Route the whole cycle to the writer so the repair writes (merge!/mark_reconciled!/refresh!)
28
+ # land on the primary; DataVerifier.verify nests its own reading role, so verification still
29
+ # reads from the replica while repair stays on the primary.
30
+ ConnectionRouting.maintenance do
31
+ divergent = []
32
+ next emit(build) unless @view_class.materialized? # cold view: no cache to verify or repair
33
+
34
+ @view_class.refresh! # drain pending maintenance so any remaining drift is genuine
35
+ divergent = detect_drift
36
+ repair!(divergent) unless divergent.empty?
37
+ mark_reconciled!(divergent)
38
+ emit(build(repaired_keys: divergent))
39
+ rescue Refresher::AlreadyRefreshingError
40
+ # A live refresh owns the cycle; the scoped repair we queued (if any) drains on
41
+ # that cycle or the next tick. Defer without corrupting or double-maintaining.
42
+ emit(build(repaired_keys: divergent || [], deferred: true))
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def detect_drift
49
+ DataVerifier.new(@view_class, mode: @mode, sample: @sample).verify.divergent_keys
50
+ end
51
+
52
+ # Queue a scoped recompute of the divergent partitions and apply it in place —
53
+ # re-aggregating missing/mismatched partitions and dropping extra ones uniformly.
54
+ def repair!(divergent)
55
+ MaintenanceStore.new(@view_class).merge!(MaintenanceDelta.scoped(divergent))
56
+ @view_class.refresh!
57
+ end
58
+
59
+ def mark_reconciled!(divergent)
60
+ Metadata::Reconciliation.mark!(@view_class.metadata, repaired_partition_count: divergent.size)
61
+ end
62
+
63
+ def build(repaired_keys: [], deferred: false)
64
+ ReconcileResult.new(
65
+ view_name: @view_class.view_key, mode: @mode, repaired_keys: repaired_keys, deferred: deferred
66
+ )
67
+ end
68
+
69
+ def emit(outcome)
70
+ Instrumentation.reconcile(
71
+ @view_class,
72
+ mode: outcome.mode, repaired_partition_count: outcome.repaired_partition_count,
73
+ deferred: outcome.deferred
74
+ )
75
+ outcome
76
+ end
77
+ end
78
+ end
79
+ end
@@ -1,57 +1,43 @@
1
- # typed: strict
2
1
  # frozen_string_literal: true
3
2
 
4
3
  module ActiveRecord
5
4
  module Materialized
6
5
  # Adds `before_refresh` / `after_refresh` lifecycle callbacks to a {View}.
7
6
  module RefreshCallbacks
8
- extend T::Sig
9
-
10
- sig { params(base: T.class_of(View)).void }
11
7
  def self.included(base)
12
8
  base.extend(ClassMethods)
13
9
  end
14
10
 
15
11
  # The callback-registration methods available on a {View} subclass.
16
12
  module ClassMethods
17
- extend T::Sig
18
-
19
- sig { returns(T::Hash[Symbol, T::Array[RefreshCallbackName]]) }
20
13
  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
- )
14
+ @refresh_callback_store ||= { before_refresh: [], after_refresh: [] }
25
15
  end
26
16
 
27
- sig { params(methods: Symbol, block: T.nilable(T.proc.void)).void }
28
17
  def before_refresh(*methods, &block)
29
18
  register_refresh_callback(:before_refresh, methods, block)
30
19
  end
31
20
 
32
- sig { params(methods: Symbol, block: T.nilable(T.proc.void)).void }
33
21
  def after_refresh(*methods, &block)
34
22
  register_refresh_callback(:after_refresh, methods, block)
35
23
  end
36
24
 
37
- sig { params(name: Symbol).void }
38
25
  def run_refresh_callbacks(name)
39
26
  callbacks = refresh_callback_store.fetch(name, [])
40
27
  callbacks.each do |callback|
41
28
  case callback
42
29
  when Symbol
43
- T.unsafe(self).public_send(callback)
30
+ public_send(callback)
44
31
  when Proc
45
- T.unsafe(self).instance_eval(&callback)
32
+ instance_eval(&callback)
46
33
  end
47
34
  end
48
35
  end
49
36
 
50
37
  private
51
38
 
52
- sig { params(name: Symbol, methods: T::Array[Symbol], block: T.nilable(T.proc.void)).void }
53
39
  def register_refresh_callback(name, methods, block)
54
- callbacks = T.must(refresh_callback_store[name]).dup
40
+ callbacks = refresh_callback_store[name].dup
55
41
  methods.each { |method| callbacks << method }
56
42
  callbacks << block if block
57
43
  refresh_callback_store[name] = callbacks
@@ -1,15 +1,11 @@
1
- # typed: strict
2
1
  # frozen_string_literal: true
3
2
 
4
3
  module ActiveRecord
5
4
  module Materialized
6
5
  # ActiveJob wrapper that runs a view's incremental refresh on a background worker.
7
6
  class RefreshJob < ::ActiveJob::Base
8
- extend T::Sig
9
-
10
7
  queue_as { ::ActiveRecord::Materialized.configuration.refresh_queue_name }
11
8
 
12
- sig { params(view_key: String).void }
13
9
  def perform(view_key)
14
10
  view_class = Registry.find(view_key)
15
11
  return if view_class.nil?
@@ -1,4 +1,3 @@
1
- # typed: strict
2
1
  # frozen_string_literal: true
3
2
 
4
3
  module ActiveRecord
@@ -18,20 +17,13 @@ module ActiveRecord
18
17
  # @return [Time, nil] when the refresh completed (nil when skipped)
19
18
  # @!attribute [r] skipped
20
19
  # @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
20
+ RefreshResult = Data.define(:view_class, :row_count, :duration_ms, :refreshed_at, :skipped) do
21
+ def initialize(view_class:, row_count:, duration_ms:, refreshed_at: nil, skipped: false) = super
29
22
 
30
23
  # A no-op result, returned when refresh! was requested on a view that is
31
24
  # not maintainable.
32
25
  #
33
26
  # @return [RefreshResult]
34
- sig { params(view_class: T.class_of(View)).returns(RefreshResult) }
35
27
  def self.skipped(view_class)
36
28
  new(view_class: view_class, row_count: 0, duration_ms: 0, refreshed_at: nil, skipped: true)
37
29
  end
@@ -1,4 +1,3 @@
1
- # typed: strict
2
1
  # frozen_string_literal: true
3
2
 
4
3
  module ActiveRecord
@@ -7,10 +6,11 @@ module ActiveRecord
7
6
  #
8
7
  # @api private
9
8
  class RefreshScheduler
10
- class << self
11
- extend T::Sig
9
+ # The refresh strategies a view may declare; the single source of truth shared by
10
+ # the +refresh_on_change+ DSL (fail-fast validation) and this dispatcher.
11
+ STRATEGIES = %i[async immediate manual].freeze
12
12
 
13
- sig { params(view_class: ViewClass).void }
13
+ class << self
14
14
  def schedule(view_class)
15
15
  # Capture the transition before marking dirty so the async dispatcher
16
16
  # can coalesce: a bulk write only needs one job for the whole burst.
@@ -31,19 +31,17 @@ module ActiveRecord
31
31
 
32
32
  private
33
33
 
34
- sig { params(view_class: ViewClass, newly_dirty: T::Boolean).void }
35
34
  def dispatch_async(view_class, newly_dirty)
36
35
  if use_active_job?
37
36
  # ActiveJob has no enqueue-level coalescing, so only enqueue when the
38
37
  # view first goes dirty; the job drains the accumulated payload. The
39
38
  # in-process refresher already coalesces via its debounce timer.
40
- T.unsafe(RefreshJob).perform_later(view_class.view_key) if newly_dirty
39
+ RefreshJob.perform_later(view_class.view_key) if newly_dirty
41
40
  else
42
41
  AsyncRefresher.enqueue(view_class)
43
42
  end
44
43
  end
45
44
 
46
- sig { returns(T::Boolean) }
47
45
  def use_active_job?
48
46
  config = ActiveRecord::Materialized.configuration
49
47
  !!(config.refresh_dispatcher == :active_job && defined?(ActiveJob::Base))
@@ -1,4 +1,3 @@
1
- # typed: strict
2
1
  # frozen_string_literal: true
3
2
 
4
3
  module ActiveRecord
@@ -7,41 +6,59 @@ module ActiveRecord
7
6
  #
8
7
  # @api private
9
8
  class Refresher
10
- extend T::Sig
11
-
12
9
  # Raised when a refresh or rebuild fails.
13
10
  class RefreshError < StandardError; end
14
11
 
15
- sig { returns(ViewClass) }
12
+ # Raised when a cycle can't start because another is already running for the
13
+ # view. A subclass of {RefreshError} so existing rescues still catch it, but
14
+ # distinct so a caller (reconciliation) can treat an overlap as a benign,
15
+ # retryable defer — and so it is re-raised without {#fail_refresh!}, which would
16
+ # otherwise mark the view failed and clear the *live* cycle's `refreshing` guard.
17
+ class AlreadyRefreshingError < RefreshError; end
18
+
16
19
  attr_reader :view_class
17
20
 
18
- sig { params(view_class: ViewClass).void }
19
21
  def initialize(view_class)
20
22
  @view_class = view_class
21
- @metadata = T.let(nil, T.nilable(Metadata))
23
+ @metadata = nil
22
24
  end
23
25
 
24
26
  # Full materialization — the only path that scans all base data.
25
- sig { returns(RefreshResult) }
26
27
  def rebuild!
27
- run_cycle(-> { perform_rebuild! })
28
- rescue StandardError => e
29
- fail_refresh!(e)
28
+ guarded_maintenance do
29
+ Instrumentation.refresh(view_class, operation: :rebuild) do |payload|
30
+ payload[:mode] = :full
31
+ run_cycle(-> { perform_rebuild! })
32
+ end
33
+ end
30
34
  end
31
35
 
32
36
  # Incremental maintenance only; a no-op when the view is not maintainable.
33
- sig { returns(RefreshResult) }
34
37
  def refresh!
35
- return RefreshResult.skipped(view_class) unless maintainable?
38
+ guarded_maintenance do
39
+ Instrumentation.refresh(view_class, operation: :incremental) do |payload|
40
+ next RefreshResult.skipped(view_class) unless maintainable?
36
41
 
37
- run_cycle(-> { incremental_refresh! })
38
- rescue StandardError => e
39
- fail_refresh!(e)
42
+ run_cycle(-> { IncrementalRefresh.new(view_class, payload).call })
43
+ end
44
+ end
40
45
  end
41
46
 
42
47
  private
43
48
 
44
- sig { returns(T::Boolean) }
49
+ # Runs a maintenance cycle under the configured writer role, translating an overlap into a
50
+ # benign re-raise (a live cycle owns the guard) and any other failure into fail_refresh!. The
51
+ # rescue lives inside the routed connection so fail_refresh!'s write also lands on the writer.
52
+ def guarded_maintenance
53
+ ConnectionRouting.maintenance do
54
+ yield
55
+ rescue AlreadyRefreshingError
56
+ raise # a live cycle owns the guard; don't fail_refresh! it (that would clear the guard)
57
+ rescue StandardError => e
58
+ fail_refresh!(e)
59
+ end
60
+ end
61
+
45
62
  def maintainable?
46
63
  return false unless view_class.incrementally_maintainable?
47
64
 
@@ -55,9 +72,8 @@ module ActiveRecord
55
72
  true
56
73
  end
57
74
 
58
- sig { params(operation: T.proc.returns(Integer)).returns(RefreshResult) }
59
75
  def run_cycle(operation)
60
- raise RefreshError, "#{view_class.name} is already refreshing" if metadata.refreshing?
76
+ raise AlreadyRefreshingError, "#{view_class.name} is already refreshing" if metadata.refreshing?
61
77
 
62
78
  started_at = monotonic_clock
63
79
  metadata.mark_refreshing!
@@ -69,7 +85,6 @@ module ActiveRecord
69
85
  result
70
86
  end
71
87
 
72
- sig { returns(Integer) }
73
88
  def perform_rebuild!
74
89
  row_count = RelationCacheWriter.new(view_class).atomic_swap!(view_class.resolved_source)
75
90
  metadata.mark_warm!
@@ -78,53 +93,23 @@ module ActiveRecord
78
93
  row_count
79
94
  end
80
95
 
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
96
  def fail_refresh!(error)
108
97
  metadata.mark_failed!(error)
109
98
  raise RefreshError, "Failed to refresh #{view_class.name}: #{error.message}", error.backtrace
110
99
  end
111
100
 
112
- sig { returns(Metadata) }
113
101
  def metadata
114
102
  @metadata ||= view_class.metadata
115
103
  end
116
104
 
117
- sig { returns(Float) }
118
105
  def monotonic_clock
119
- T.cast(Process.clock_gettime(Process::CLOCK_MONOTONIC), Float)
106
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
120
107
  end
121
108
 
122
- sig { params(started_at: Float).returns(Integer) }
123
109
  def elapsed_milliseconds(started_at)
124
110
  ((monotonic_clock - started_at) * 1000).round
125
111
  end
126
112
 
127
- sig { params(row_count: Integer, duration_ms: Integer).returns(RefreshResult) }
128
113
  def complete_refresh!(row_count:, duration_ms:)
129
114
  metadata.mark_refreshed!(row_count: row_count, duration_ms: duration_ms)
130
115
  RefreshResult.new(