activerecord-materialized 0.1.1 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +93 -0
- data/README.md +138 -323
- data/lib/activerecord/materialized/aggregate_analysis.rb +10 -31
- data/lib/activerecord/materialized/async_refresher.rb +18 -27
- data/lib/activerecord/materialized/cache_table_schema.rb +53 -34
- data/lib/activerecord/materialized/change_source.rb +25 -0
- data/lib/activerecord/materialized/cold_read.rb +1 -10
- data/lib/activerecord/materialized/column_type_inference.rb +167 -0
- data/lib/activerecord/materialized/configurable_table_name.rb +45 -0
- data/lib/activerecord/materialized/configuration.rb +104 -29
- data/lib/activerecord/materialized/connection_routing.rb +29 -0
- data/lib/activerecord/materialized/data_verification_result.rb +41 -0
- data/lib/activerecord/materialized/data_verifier.rb +162 -0
- data/lib/activerecord/materialized/debezium_envelope.rb +105 -0
- data/lib/activerecord/materialized/delta_maintainer.rb +4 -15
- data/lib/activerecord/materialized/dependency_registry.rb +43 -35
- data/lib/activerecord/materialized/dependency_trackable.rb +7 -14
- data/lib/activerecord/materialized/incremental_maintainer.rb +45 -28
- data/lib/activerecord/materialized/incremental_refresh.rb +63 -0
- data/lib/activerecord/materialized/instrumentation.rb +102 -0
- data/lib/activerecord/materialized/maintenance_delta.rb +4 -19
- data/lib/activerecord/materialized/maintenance_delta_builder.rb +46 -14
- data/lib/activerecord/materialized/maintenance_store.rb +58 -21
- data/lib/activerecord/materialized/metadata/maintenance_payload.rb +1 -7
- data/lib/activerecord/materialized/metadata/reconciliation.rb +25 -0
- data/lib/activerecord/materialized/metadata/schema.rb +19 -41
- data/lib/activerecord/materialized/metadata/timestamps.rb +0 -5
- data/lib/activerecord/materialized/metadata.rb +41 -25
- data/lib/activerecord/materialized/metadata_record.rb +2 -16
- data/lib/activerecord/materialized/migration_builder.rb +8 -8
- data/lib/activerecord/materialized/module_api.rb +219 -22
- data/lib/activerecord/materialized/partition_filter.rb +45 -0
- data/lib/activerecord/materialized/partition_keyed_store.rb +72 -0
- data/lib/activerecord/materialized/partition_record.rb +2 -16
- data/lib/activerecord/materialized/partition_snapshot.rb +79 -0
- data/lib/activerecord/materialized/partition_state.rb +47 -46
- data/lib/activerecord/materialized/query_expressions.rb +0 -12
- data/lib/activerecord/materialized/railtie.rb +15 -3
- data/lib/activerecord/materialized/read_router.rb +51 -0
- data/lib/activerecord/materialized/reconcile_job.rb +22 -0
- data/lib/activerecord/materialized/reconcile_result.rb +36 -0
- data/lib/activerecord/materialized/reconciler.rb +79 -0
- data/lib/activerecord/materialized/refresh_callbacks.rb +4 -18
- data/lib/activerecord/materialized/refresh_job.rb +0 -4
- data/lib/activerecord/materialized/refresh_result.rb +2 -10
- data/lib/activerecord/materialized/refresh_scheduler.rb +5 -7
- data/lib/activerecord/materialized/refresher.rb +35 -50
- data/lib/activerecord/materialized/registry.rb +26 -16
- data/lib/activerecord/materialized/relation_cache_writer.rb +12 -46
- data/lib/activerecord/materialized/schema_verifier.rb +0 -9
- data/lib/activerecord/materialized/source_watermark.rb +93 -0
- data/lib/activerecord/materialized/source_watermark_record.rb +16 -0
- data/lib/activerecord/materialized/summary_delta.rb +1 -17
- data/lib/activerecord/materialized/summary_delta_builder.rb +2 -10
- data/lib/activerecord/materialized/table_model_registry.rb +1 -8
- data/lib/activerecord/materialized/table_swap.rb +106 -0
- data/lib/activerecord/materialized/tasks.rb +46 -38
- data/lib/activerecord/materialized/version.rb +1 -2
- data/lib/activerecord/materialized/view.rb +11 -22
- data/lib/activerecord/materialized/view_configuration_class_methods.rb +23 -35
- data/lib/activerecord/materialized/view_definition.rb +76 -65
- data/lib/activerecord/materialized/view_incremental_class_methods.rb +57 -60
- data/lib/activerecord/materialized/view_loader.rb +49 -0
- data/lib/activerecord/materialized/view_query_access_class_methods.rb +100 -57
- data/lib/activerecord/materialized/view_refresh_policy_class_methods.rb +70 -37
- data/lib/activerecord/materialized/write_change.rb +75 -26
- data/lib/activerecord/materialized/write_maintenance.rb +73 -0
- data/lib/activerecord/materialized/write_outbox.rb +345 -0
- data/lib/activerecord/materialized/write_outbox_record.rb +17 -0
- data/lib/activerecord/materialized.rb +27 -6
- data/lib/generators/activerecord_materialized/install/templates/create_ar_materialized_view_metadata.rb.erb +14 -0
- data/lib/generators/activerecord_materialized/install_generator.rb +1 -6
- data/lib/generators/activerecord_materialized/migration_generator.rb +3 -9
- data/lib/generators/activerecord_materialized/outbox_generator.rb +36 -0
- data/lib/generators/activerecord_materialized/templates/materialized_view_migration.rb.erb +5 -1
- data/lib/generators/activerecord_materialized/templates/write_outbox_migration.rb.erb +21 -0
- data/lib/generators/activerecord_materialized/view_generator.rb +0 -4
- metadata +27 -18
- data/lib/activerecord/materialized/type_reexports.rb +0 -14
- data/lib/activerecord/materialized/view_class.rb +0 -8
- 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 ||=
|
|
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
|
-
|
|
30
|
+
public_send(callback)
|
|
44
31
|
when Proc
|
|
45
|
-
|
|
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 =
|
|
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
|
-
|
|
22
|
-
|
|
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
|
-
|
|
11
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
38
|
+
guarded_maintenance do
|
|
39
|
+
Instrumentation.refresh(view_class, operation: :incremental) do |payload|
|
|
40
|
+
next RefreshResult.skipped(view_class) unless maintainable?
|
|
36
41
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
42
|
+
run_cycle(-> { IncrementalRefresh.new(view_class, payload).call })
|
|
43
|
+
end
|
|
44
|
+
end
|
|
40
45
|
end
|
|
41
46
|
|
|
42
47
|
private
|
|
43
48
|
|
|
44
|
-
|
|
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
|
|
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
|
-
|
|
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(
|