activerecord-materialized 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +37 -0
- data/LICENSE +21 -0
- data/README.md +526 -0
- data/lib/activerecord/materialized/aggregate_analysis.rb +132 -0
- data/lib/activerecord/materialized/async_refresher.rb +105 -0
- data/lib/activerecord/materialized/cache_table_schema.rb +67 -0
- data/lib/activerecord/materialized/cold_read.rb +60 -0
- data/lib/activerecord/materialized/configuration.rb +80 -0
- data/lib/activerecord/materialized/delta_maintainer.rb +74 -0
- data/lib/activerecord/materialized/dependency_registry.rb +107 -0
- data/lib/activerecord/materialized/dependency_trackable.rb +48 -0
- data/lib/activerecord/materialized/incremental_maintainer.rb +58 -0
- data/lib/activerecord/materialized/maintenance_delta.rb +82 -0
- data/lib/activerecord/materialized/maintenance_delta_builder.rb +62 -0
- data/lib/activerecord/materialized/maintenance_store.rb +82 -0
- data/lib/activerecord/materialized/metadata/maintenance_payload.rb +33 -0
- data/lib/activerecord/materialized/metadata/schema.rb +84 -0
- data/lib/activerecord/materialized/metadata/timestamps.rb +31 -0
- data/lib/activerecord/materialized/metadata.rb +138 -0
- data/lib/activerecord/materialized/metadata_record.rb +28 -0
- data/lib/activerecord/materialized/migration_builder.rb +38 -0
- data/lib/activerecord/materialized/module_api.rb +82 -0
- data/lib/activerecord/materialized/partition_record.rb +27 -0
- data/lib/activerecord/materialized/partition_state.rb +127 -0
- data/lib/activerecord/materialized/query_expressions.rb +83 -0
- data/lib/activerecord/materialized/railtie.rb +16 -0
- data/lib/activerecord/materialized/refresh_callbacks.rb +62 -0
- data/lib/activerecord/materialized/refresh_job.rb +22 -0
- data/lib/activerecord/materialized/refresh_result.rb +40 -0
- data/lib/activerecord/materialized/refresh_scheduler.rb +54 -0
- data/lib/activerecord/materialized/refresher.rb +139 -0
- data/lib/activerecord/materialized/registry.rb +74 -0
- data/lib/activerecord/materialized/relation_cache_writer.rb +137 -0
- data/lib/activerecord/materialized/schema_verifier.rb +64 -0
- data/lib/activerecord/materialized/summary_delta.rb +76 -0
- data/lib/activerecord/materialized/summary_delta_builder.rb +58 -0
- data/lib/activerecord/materialized/table_model_registry.rb +43 -0
- data/lib/activerecord/materialized/tasks.rb +79 -0
- data/lib/activerecord/materialized/type_reexports.rb +14 -0
- data/lib/activerecord/materialized/version.rb +9 -0
- data/lib/activerecord/materialized/view.rb +79 -0
- data/lib/activerecord/materialized/view_class.rb +8 -0
- data/lib/activerecord/materialized/view_configuration_class_methods.rb +103 -0
- data/lib/activerecord/materialized/view_definition.rb +133 -0
- data/lib/activerecord/materialized/view_incremental_class_methods.rb +142 -0
- data/lib/activerecord/materialized/view_query_access_class_methods.rb +160 -0
- data/lib/activerecord/materialized/view_refresh_policy_class_methods.rb +109 -0
- data/lib/activerecord/materialized/write_change.rb +69 -0
- data/lib/activerecord/materialized.rb +55 -0
- data/lib/activerecord_materialized_types.rb +18 -0
- data/lib/generators/activerecord_materialized/install/templates/README +55 -0
- data/lib/generators/activerecord_materialized/install/templates/create_ar_materialized_view_metadata.rb.erb +30 -0
- data/lib/generators/activerecord_materialized/install_generator.rb +32 -0
- data/lib/generators/activerecord_materialized/migration_generator.rb +51 -0
- data/lib/generators/activerecord_materialized/templates/materialized_view.rb.erb +17 -0
- data/lib/generators/activerecord_materialized/templates/materialized_view_migration.rb.erb +11 -0
- data/lib/generators/activerecord_materialized/view_generator.rb +18 -0
- metadata +162 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
module Materialized
|
|
6
|
+
# Hot-path scoped recompute: deletes and re-aggregates only the affected partitions in place.
|
|
7
|
+
#
|
|
8
|
+
# @api private
|
|
9
|
+
class IncrementalMaintainer
|
|
10
|
+
extend T::Sig
|
|
11
|
+
|
|
12
|
+
sig { params(view_class: ViewClass).void }
|
|
13
|
+
def initialize(view_class)
|
|
14
|
+
@view_class = view_class
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
sig { params(_connection: Connection, _table_name: String).returns(Integer) }
|
|
18
|
+
def maintain!(_connection, _table_name)
|
|
19
|
+
delta = maintenance_store.consume_pending_delta!
|
|
20
|
+
relation = resolve_maintenance_relation(delta)
|
|
21
|
+
|
|
22
|
+
row_count = RelationCacheWriter.new(view_class).replace_partitions!(
|
|
23
|
+
relation,
|
|
24
|
+
key_tuples: delta.key_tuples,
|
|
25
|
+
full_partition: delta.full_partition?
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# On a cold view the maintained partitions are now fresh.
|
|
29
|
+
unless delta.full_partition? || view_class.materialized?
|
|
30
|
+
PartitionState.new(view_class).mark_fresh!(delta.key_tuples)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
row_count
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
sig { returns(ViewClass) }
|
|
39
|
+
attr_reader :view_class
|
|
40
|
+
|
|
41
|
+
sig { returns(MaintenanceStore) }
|
|
42
|
+
def maintenance_store
|
|
43
|
+
MaintenanceStore.new(view_class)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
sig { params(delta: MaintenanceDelta).returns(::ActiveRecord::Relation) }
|
|
47
|
+
def resolve_maintenance_relation(delta)
|
|
48
|
+
if view_class.incremental_source_override?
|
|
49
|
+
view_class.resolved_incremental_source
|
|
50
|
+
elsif delta.full_partition?
|
|
51
|
+
view_class.resolved_source
|
|
52
|
+
else
|
|
53
|
+
view_class.view_definition.partition_scope(delta.key_tuples)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
module Materialized
|
|
6
|
+
# Pending scoped-recompute maintenance: the affected partition keys, or a full-partition marker.
|
|
7
|
+
#
|
|
8
|
+
# @api private
|
|
9
|
+
class MaintenanceDelta
|
|
10
|
+
extend T::Sig
|
|
11
|
+
|
|
12
|
+
SCOPED = T.let(:scoped, Symbol)
|
|
13
|
+
FULL = T.let(:full_partition, Symbol)
|
|
14
|
+
|
|
15
|
+
sig { returns(Symbol) }
|
|
16
|
+
attr_reader :scope
|
|
17
|
+
|
|
18
|
+
sig { returns(T::Array[T::Array[String]]) }
|
|
19
|
+
attr_reader :key_tuples
|
|
20
|
+
|
|
21
|
+
sig { params(scope: Symbol, key_tuples: T::Array[T::Array[String]]).void }
|
|
22
|
+
def initialize(scope:, key_tuples: [])
|
|
23
|
+
@scope = scope
|
|
24
|
+
@key_tuples = key_tuples
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
sig { params(key_tuples: T::Array[T::Array[String]]).returns(MaintenanceDelta) }
|
|
28
|
+
def self.scoped(key_tuples)
|
|
29
|
+
new(scope: SCOPED, key_tuples: key_tuples)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
sig { returns(MaintenanceDelta) }
|
|
33
|
+
def self.full_partition
|
|
34
|
+
new(scope: FULL)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
sig { returns(T::Boolean) }
|
|
38
|
+
def full_partition?
|
|
39
|
+
scope == FULL
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# How many distinct partitions this pending maintenance tracks. A
|
|
43
|
+
# full-partition recompute tracks none — it is already the collapsed form.
|
|
44
|
+
sig { returns(Integer) }
|
|
45
|
+
def tracked_partition_count
|
|
46
|
+
full_partition? ? 0 : key_tuples.size
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
sig { params(other: MaintenanceDelta).returns(MaintenanceDelta) }
|
|
50
|
+
def merge(other)
|
|
51
|
+
return other if other.full_partition?
|
|
52
|
+
return self if full_partition?
|
|
53
|
+
|
|
54
|
+
combined = (key_tuples + other.key_tuples).uniq
|
|
55
|
+
self.class.scoped(combined)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
sig { returns(T::Hash[String, T.untyped]) }
|
|
59
|
+
def serialize
|
|
60
|
+
{
|
|
61
|
+
"scope" => scope.to_s,
|
|
62
|
+
"key_tuples" => key_tuples
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
sig { params(payload: T.nilable(T::Hash[String, T.untyped])).returns(T.nilable(MaintenanceDelta)) }
|
|
67
|
+
def self.deserialize(payload)
|
|
68
|
+
return nil if payload.blank?
|
|
69
|
+
|
|
70
|
+
scope_name = payload["scope"]&.to_sym
|
|
71
|
+
return nil if scope_name.nil?
|
|
72
|
+
|
|
73
|
+
if scope_name == FULL
|
|
74
|
+
full_partition
|
|
75
|
+
else
|
|
76
|
+
tuples = T.cast(payload["key_tuples"], T.nilable(T::Array[T::Array[String]])) || []
|
|
77
|
+
scoped(tuples)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
module Materialized
|
|
6
|
+
# Derives the affected partition keys for a write from its ActiveRecord change payload.
|
|
7
|
+
#
|
|
8
|
+
# @api private
|
|
9
|
+
class MaintenanceDeltaBuilder
|
|
10
|
+
extend T::Sig
|
|
11
|
+
|
|
12
|
+
sig { params(change: WriteChange, key_columns: T::Array[String]).void }
|
|
13
|
+
def initialize(change, key_columns)
|
|
14
|
+
@change = change
|
|
15
|
+
@key_columns = key_columns
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
sig { returns(MaintenanceDelta) }
|
|
19
|
+
def build
|
|
20
|
+
return MaintenanceDelta.full_partition if @key_columns.empty?
|
|
21
|
+
|
|
22
|
+
tuples = extract_tuples
|
|
23
|
+
tuples.empty? ? MaintenanceDelta.full_partition : MaintenanceDelta.scoped(tuples.uniq)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
sig { returns(T::Array[T::Array[T.untyped]]) }
|
|
29
|
+
def extract_tuples
|
|
30
|
+
snapshots = case @change.operation.to_sym
|
|
31
|
+
when :create then [@change.after]
|
|
32
|
+
when :destroy then [@change.before]
|
|
33
|
+
when :update then [@change.before, @change.after]
|
|
34
|
+
else []
|
|
35
|
+
end
|
|
36
|
+
snapshots.filter_map { |attributes| key_tuple(attributes) }.uniq
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
sig { params(attributes: T::Hash[String, T.untyped]).returns(T::Boolean) }
|
|
40
|
+
def keys_present?(attributes)
|
|
41
|
+
@key_columns.all? do |column|
|
|
42
|
+
attributes.key?(column) || T.unsafe(attributes).key?(column.to_sym)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
sig { params(attributes: T::Hash[String, T.untyped]).returns(T.nilable(T::Array[T.untyped])) }
|
|
47
|
+
def key_tuple(attributes)
|
|
48
|
+
return nil unless keys_present?(attributes)
|
|
49
|
+
|
|
50
|
+
@key_columns.map { |column| attribute_value(attributes, column) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
sig { params(attributes: T::Hash[String, T.untyped], column: String).returns(T.untyped) }
|
|
54
|
+
def attribute_value(attributes, column)
|
|
55
|
+
# Look up by presence so falsey group-key values map to their partition.
|
|
56
|
+
return attributes[column] if attributes.key?(column)
|
|
57
|
+
|
|
58
|
+
T.unsafe(attributes)[column.to_sym]
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
module Materialized
|
|
6
|
+
# Persists a view's pending maintenance (a delta or a scope) in its metadata row.
|
|
7
|
+
#
|
|
8
|
+
# @api private
|
|
9
|
+
class MaintenanceStore
|
|
10
|
+
extend T::Sig
|
|
11
|
+
|
|
12
|
+
Pending = T.type_alias { T.any(SummaryDelta, MaintenanceDelta) }
|
|
13
|
+
|
|
14
|
+
sig { params(view_class: ViewClass).void }
|
|
15
|
+
def initialize(view_class)
|
|
16
|
+
@view_class = view_class
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Accumulates pending maintenance of either kind. A view's mode is fixed
|
|
20
|
+
# within a window, so existing pending is always the same kind. Once the
|
|
21
|
+
# tracked partitions exceed the configured cap, the payload collapses to a
|
|
22
|
+
# single full recompute, so a bulk write spanning many partitions stays
|
|
23
|
+
# O(1) per write instead of re-serializing an ever-growing blob.
|
|
24
|
+
sig { params(delta: Pending).void }
|
|
25
|
+
def merge!(delta)
|
|
26
|
+
metadata.record_maintenance_payload!(T.unsafe(combine(pending, delta)).serialize)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
sig { returns(T.nilable(Pending)) }
|
|
30
|
+
def pending
|
|
31
|
+
payload = metadata.maintenance_payload
|
|
32
|
+
SummaryDelta.deserialize(payload) || MaintenanceDelta.deserialize(payload)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
sig { returns(T.nilable(MaintenanceDelta)) }
|
|
36
|
+
def pending_delta
|
|
37
|
+
MaintenanceDelta.deserialize(metadata.maintenance_payload)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
sig { returns(MaintenanceDelta) }
|
|
41
|
+
def consume_pending_delta!
|
|
42
|
+
delta = pending_delta || MaintenanceDelta.full_partition
|
|
43
|
+
clear!
|
|
44
|
+
delta
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
sig { void }
|
|
48
|
+
def clear!
|
|
49
|
+
metadata.clear_maintenance_payload!
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
sig { params(current: T.nilable(Pending), delta: Pending).returns(Pending) }
|
|
55
|
+
def combine(current, delta)
|
|
56
|
+
return delta if current.nil?
|
|
57
|
+
return current if recompute_all?(current) # terminal: absorb everything
|
|
58
|
+
|
|
59
|
+
merged = current.instance_of?(delta.class) ? T.unsafe(current).merge(delta) : delta
|
|
60
|
+
oversized?(merged) ? MaintenanceDelta.full_partition : merged
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
sig { params(pending: T.nilable(Pending)).returns(T::Boolean) }
|
|
64
|
+
def recompute_all?(pending)
|
|
65
|
+
pending.is_a?(MaintenanceDelta) && pending.full_partition?
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
sig { params(merged: Pending).returns(T::Boolean) }
|
|
69
|
+
def oversized?(merged)
|
|
70
|
+
merged.tracked_partition_count > ActiveRecord::Materialized.configuration.max_tracked_partitions
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
sig { returns(ViewClass) }
|
|
74
|
+
attr_reader :view_class
|
|
75
|
+
|
|
76
|
+
sig { returns(Metadata) }
|
|
77
|
+
def metadata
|
|
78
|
+
view_class.metadata
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
module Materialized
|
|
6
|
+
class Metadata
|
|
7
|
+
# Reads and writes the serialized pending-maintenance payload on the metadata row.
|
|
8
|
+
#
|
|
9
|
+
# @api private
|
|
10
|
+
module MaintenancePayload
|
|
11
|
+
extend T::Sig
|
|
12
|
+
|
|
13
|
+
sig { params(metadata: Metadata, payload: T::Hash[String, T.untyped]).void }
|
|
14
|
+
def self.record!(metadata, payload)
|
|
15
|
+
metadata.record.update!(maintenance_payload: payload.to_json)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
sig { params(metadata: Metadata).returns(T.nilable(T::Hash[String, T.untyped])) }
|
|
19
|
+
def self.fetch(metadata)
|
|
20
|
+
raw = T.unsafe(metadata.record).maintenance_payload
|
|
21
|
+
return nil if raw.blank?
|
|
22
|
+
|
|
23
|
+
JSON.parse(raw)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
sig { params(metadata: Metadata).void }
|
|
27
|
+
def self.clear!(metadata)
|
|
28
|
+
metadata.record.update!(maintenance_payload: nil)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
module Materialized
|
|
6
|
+
class Metadata
|
|
7
|
+
# Lazily provisions and migrates the materialized-view metadata table.
|
|
8
|
+
#
|
|
9
|
+
# @api private
|
|
10
|
+
module Schema
|
|
11
|
+
extend T::Sig
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
sig { params(view_class: ViewClass).void }
|
|
16
|
+
def ensure_table!(view_class)
|
|
17
|
+
connection = view_class.connection
|
|
18
|
+
create_metadata_table!(connection) unless MetadataRecord.table_exists?
|
|
19
|
+
ensure_dirty_column!(connection)
|
|
20
|
+
ensure_maintenance_payload_column!(connection)
|
|
21
|
+
ensure_warm_column!(connection)
|
|
22
|
+
MetadataRecord.reset_column_information
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
sig { params(connection: Connection).void }
|
|
26
|
+
def create_metadata_table!(connection)
|
|
27
|
+
connection.create_table(::ActiveRecord::Materialized.metadata_table_name, force: :cascade) do |t|
|
|
28
|
+
t.string :view_name, null: false
|
|
29
|
+
t.datetime :last_refreshed_at
|
|
30
|
+
t.boolean :refreshing, null: false, default: false
|
|
31
|
+
t.boolean :dirty, null: false, default: true
|
|
32
|
+
t.boolean :warm, null: false, default: false
|
|
33
|
+
t.integer :row_count
|
|
34
|
+
t.integer :refresh_duration_ms
|
|
35
|
+
t.text :last_error
|
|
36
|
+
t.text :maintenance_payload
|
|
37
|
+
t.timestamps
|
|
38
|
+
end
|
|
39
|
+
connection.add_index(::ActiveRecord::Materialized.metadata_table_name, :view_name, unique: true)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
sig { params(connection: Connection).void }
|
|
43
|
+
def ensure_dirty_column!(connection)
|
|
44
|
+
return unless MetadataRecord.table_exists?
|
|
45
|
+
return if MetadataRecord.column_names.include?("dirty")
|
|
46
|
+
|
|
47
|
+
connection.add_column(
|
|
48
|
+
::ActiveRecord::Materialized.metadata_table_name,
|
|
49
|
+
:dirty,
|
|
50
|
+
:boolean,
|
|
51
|
+
default: true,
|
|
52
|
+
null: false
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
sig { params(connection: Connection).void }
|
|
57
|
+
def ensure_maintenance_payload_column!(connection)
|
|
58
|
+
return unless MetadataRecord.table_exists?
|
|
59
|
+
return if MetadataRecord.column_names.include?("maintenance_payload")
|
|
60
|
+
|
|
61
|
+
connection.add_column(
|
|
62
|
+
::ActiveRecord::Materialized.metadata_table_name,
|
|
63
|
+
:maintenance_payload,
|
|
64
|
+
:text
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
sig { params(connection: Connection).void }
|
|
69
|
+
def ensure_warm_column!(connection)
|
|
70
|
+
return unless MetadataRecord.table_exists?
|
|
71
|
+
return if MetadataRecord.column_names.include?("warm")
|
|
72
|
+
|
|
73
|
+
connection.add_column(
|
|
74
|
+
::ActiveRecord::Materialized.metadata_table_name,
|
|
75
|
+
:warm,
|
|
76
|
+
:boolean,
|
|
77
|
+
default: false,
|
|
78
|
+
null: false
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
module Materialized
|
|
6
|
+
class Metadata
|
|
7
|
+
# Current-time and staleness-threshold helpers for metadata timestamps.
|
|
8
|
+
#
|
|
9
|
+
# @api private
|
|
10
|
+
module Timestamps
|
|
11
|
+
extend T::Sig
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
sig { returns(Timestamp) }
|
|
16
|
+
def current
|
|
17
|
+
::Time.zone&.now || ::Time.now.utc
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
sig { params(staleness: StalenessDuration).returns(Timestamp) }
|
|
21
|
+
def threshold(staleness)
|
|
22
|
+
if staleness.is_a?(Integer)
|
|
23
|
+
::ActiveSupport::Duration.seconds(staleness).ago
|
|
24
|
+
else
|
|
25
|
+
staleness.ago
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "metadata/schema"
|
|
5
|
+
require_relative "metadata/maintenance_payload"
|
|
6
|
+
require_relative "metadata/timestamps"
|
|
7
|
+
|
|
8
|
+
module ActiveRecord
|
|
9
|
+
module Materialized
|
|
10
|
+
# Reads and writes a view's freshness metadata row (dirty, warm, last_refreshed_at, …).
|
|
11
|
+
#
|
|
12
|
+
# @api private
|
|
13
|
+
class Metadata
|
|
14
|
+
extend T::Sig
|
|
15
|
+
|
|
16
|
+
sig { returns(ViewClass) }
|
|
17
|
+
attr_reader :view_class
|
|
18
|
+
|
|
19
|
+
sig { params(view_class: ViewClass).void }
|
|
20
|
+
def initialize(view_class)
|
|
21
|
+
@view_class = view_class
|
|
22
|
+
@schema_ensured = T.let(false, T::Boolean)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Single entry point for the metadata row. Provision the schema once per
|
|
26
|
+
# instance — re-ensuring on every access thrashes the schema cache and
|
|
27
|
+
# makes high-write workloads quadratic.
|
|
28
|
+
sig { returns(MetadataRecord) }
|
|
29
|
+
def record
|
|
30
|
+
ensure_schema!
|
|
31
|
+
MetadataRecord.find_or_initialize_by(view_name: view_class.view_key)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
sig { void }
|
|
35
|
+
def ensure_schema!
|
|
36
|
+
return if @schema_ensured
|
|
37
|
+
|
|
38
|
+
Schema.ensure_table!(view_class)
|
|
39
|
+
@schema_ensured = true
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
sig { returns(T.nilable(Timestamp)) }
|
|
43
|
+
def last_refreshed_at
|
|
44
|
+
record.last_refreshed_at
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
sig { returns(T::Boolean) }
|
|
48
|
+
def refreshing?
|
|
49
|
+
!!record.refreshing?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
sig { returns(T.nilable(Integer)) }
|
|
53
|
+
def row_count
|
|
54
|
+
record.row_count
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
sig { returns(T.nilable(Integer)) }
|
|
58
|
+
def refresh_duration_ms
|
|
59
|
+
record.refresh_duration_ms
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
sig { returns(T::Boolean) }
|
|
63
|
+
def dirty?
|
|
64
|
+
!!record.dirty?
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Warm once fully materialized via rebuild!; cold views read through.
|
|
68
|
+
sig { returns(T::Boolean) }
|
|
69
|
+
def warm?
|
|
70
|
+
!!record.warm?
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
sig { void }
|
|
74
|
+
def mark_warm!
|
|
75
|
+
record.update!(warm: true)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
sig { params(max_staleness: T.nilable(StalenessDuration)).returns(T::Boolean) }
|
|
79
|
+
def stale?(max_staleness: view_class.resolved_max_staleness)
|
|
80
|
+
return true if dirty?
|
|
81
|
+
return true if last_refreshed_at.nil?
|
|
82
|
+
return false if max_staleness.nil?
|
|
83
|
+
|
|
84
|
+
refreshed_at = T.must(last_refreshed_at)
|
|
85
|
+
refreshed_at.to_time < Timestamps.threshold(max_staleness).to_time
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
sig { void }
|
|
89
|
+
def mark_dirty!
|
|
90
|
+
record.update!(dirty: true)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
sig { params(payload: T::Hash[String, T.untyped]).void }
|
|
94
|
+
def record_maintenance_payload!(payload)
|
|
95
|
+
MaintenancePayload.record!(self, payload)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
sig { returns(T.nilable(T::Hash[String, T.untyped])) }
|
|
99
|
+
def maintenance_payload
|
|
100
|
+
MaintenancePayload.fetch(self)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
sig { void }
|
|
104
|
+
def clear_maintenance_payload!
|
|
105
|
+
MaintenancePayload.clear!(self)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
sig { void }
|
|
109
|
+
def mark_refreshing!
|
|
110
|
+
record.update!(
|
|
111
|
+
refreshing: true,
|
|
112
|
+
last_error: nil
|
|
113
|
+
)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
sig { params(row_count: Integer, duration_ms: Integer).void }
|
|
117
|
+
def mark_refreshed!(row_count:, duration_ms:)
|
|
118
|
+
record.update!(
|
|
119
|
+
last_refreshed_at: Timestamps.current,
|
|
120
|
+
refreshing: false,
|
|
121
|
+
dirty: false,
|
|
122
|
+
row_count: row_count,
|
|
123
|
+
refresh_duration_ms: duration_ms,
|
|
124
|
+
last_error: nil,
|
|
125
|
+
maintenance_payload: nil
|
|
126
|
+
)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
sig { params(error: StandardError).void }
|
|
130
|
+
def mark_failed!(error)
|
|
131
|
+
record.update!(
|
|
132
|
+
refreshing: false,
|
|
133
|
+
last_error: error.message
|
|
134
|
+
)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
module Materialized
|
|
6
|
+
# ActiveRecord model backing the materialized-view metadata table.
|
|
7
|
+
#
|
|
8
|
+
# @api private
|
|
9
|
+
class MetadataRecord < ::ActiveRecord::Base
|
|
10
|
+
extend T::Sig
|
|
11
|
+
|
|
12
|
+
@table_name_override = T.let(nil, T.nilable(String))
|
|
13
|
+
|
|
14
|
+
self.table_name = ::ActiveRecord::Materialized.metadata_table_name
|
|
15
|
+
|
|
16
|
+
sig { params(name: String).void }
|
|
17
|
+
def self.table_name=(name)
|
|
18
|
+
@table_name_override = name
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
sig { returns(String) }
|
|
22
|
+
def self.table_name
|
|
23
|
+
override = @table_name_override
|
|
24
|
+
override.nil? ? ::ActiveRecord::Materialized.metadata_table_name : override
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
module Materialized
|
|
6
|
+
# The data a generated migration needs to provision a view's empty cache
|
|
7
|
+
# table: table name, migration class name, target version, and inferred
|
|
8
|
+
# columns/types. The file itself is produced by Rails' generator tooling.
|
|
9
|
+
class MigrationBuilder
|
|
10
|
+
extend T::Sig
|
|
11
|
+
|
|
12
|
+
sig { params(view_class: ViewClass).void }
|
|
13
|
+
def initialize(view_class)
|
|
14
|
+
@view_class = view_class
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
sig { returns(String) }
|
|
18
|
+
def table_name
|
|
19
|
+
@view_class.table_name
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
sig { returns(String) }
|
|
23
|
+
def migration_class_name
|
|
24
|
+
"Create#{table_name.camelize}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
sig { returns(T.any(String, Float)) }
|
|
28
|
+
def migration_version
|
|
29
|
+
::ActiveRecord::Migration.current_version
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
sig { returns(T::Array[CacheTableSchema::ColumnDefinition]) }
|
|
33
|
+
def column_definitions
|
|
34
|
+
CacheTableSchema.column_definitions(@view_class.connection, @view_class.resolved_source)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|