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
|
|
@@ -7,11 +6,6 @@ module ActiveRecord
|
|
|
7
6
|
#
|
|
8
7
|
# @api private
|
|
9
8
|
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
9
|
def initialize(view_class)
|
|
16
10
|
@view_class = view_class
|
|
17
11
|
end
|
|
@@ -21,59 +15,102 @@ module ActiveRecord
|
|
|
21
15
|
# tracked partitions exceed the configured cap, the payload collapses to a
|
|
22
16
|
# single full recompute, so a bulk write spanning many partitions stays
|
|
23
17
|
# O(1) per write instead of re-serializing an ever-growing blob.
|
|
24
|
-
sig { params(delta: Pending).void }
|
|
25
18
|
def merge!(delta)
|
|
26
|
-
|
|
19
|
+
combined = combine(pending, delta)
|
|
20
|
+
return drop_cold_full_recompute! if cold_full_recompute?(combined)
|
|
21
|
+
|
|
22
|
+
metadata.record_maintenance_payload!(combined.serialize)
|
|
27
23
|
end
|
|
28
24
|
|
|
29
|
-
sig { returns(T.nilable(Pending)) }
|
|
30
25
|
def pending
|
|
31
26
|
payload = metadata.maintenance_payload
|
|
32
27
|
SummaryDelta.deserialize(payload) || MaintenanceDelta.deserialize(payload)
|
|
33
28
|
end
|
|
34
29
|
|
|
35
|
-
sig { returns(T.nilable(MaintenanceDelta)) }
|
|
36
30
|
def pending_delta
|
|
37
31
|
MaintenanceDelta.deserialize(metadata.maintenance_payload)
|
|
38
32
|
end
|
|
39
33
|
|
|
40
|
-
|
|
34
|
+
# Atomically consume the pending scoped delta under a row lock on the metadata row, so two
|
|
35
|
+
# cross-process cycles can't both consume it and recompute the same partition twice — which
|
|
36
|
+
# duplicated rows on Postgres (no unique constraint, no gap locks under READ COMMITTED). Whoever
|
|
37
|
+
# clears the payload owns the delta; a loser reads an empty payload and gets nil (a benign
|
|
38
|
+
# no-op). Returns the delta, or nil. Unlike the additive summary path (which applies inside its
|
|
39
|
+
# lock for crash-atomicity), the scoped recompute is idempotent (delete + re-aggregate), so the
|
|
40
|
+
# caller applies OUTSIDE this lock, keeping it short; a crash between consume and apply loses the
|
|
41
|
+
# delta, which self-healing reconciliation (#64) repairs on the next tick.
|
|
41
42
|
def consume_pending_delta!
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
metadata.record.with_lock do # blocking FOR UPDATE on the metadata row + a transaction
|
|
44
|
+
delta = pending_delta
|
|
45
|
+
next nil if delta.nil?
|
|
46
|
+
|
|
47
|
+
clear!
|
|
48
|
+
delta
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Atomically consume the pending SummaryDelta and apply it under a row lock on the metadata
|
|
53
|
+
# row, so concurrent cycles across servers can't apply the same additive delta twice. Yields
|
|
54
|
+
# the delta to the block (which applies it) inside the locked transaction, so a failed apply
|
|
55
|
+
# rolls back the clear and the delta is retried. Returns the block's result, or nil when another
|
|
56
|
+
# cycle already consumed it — the loser blocks on the lock, then reads an empty payload (a
|
|
57
|
+
# benign no-op). DML-only (no DDL/callbacks), so the lock holds for the whole critical section.
|
|
58
|
+
def with_consumed_summary_delta
|
|
59
|
+
metadata.record.with_lock do # blocking FOR UPDATE on the metadata row + a transaction
|
|
60
|
+
delta = SummaryDelta.deserialize(metadata.maintenance_payload)
|
|
61
|
+
next nil if delta.nil?
|
|
62
|
+
|
|
63
|
+
clear!
|
|
64
|
+
yield delta
|
|
65
|
+
end
|
|
45
66
|
end
|
|
46
67
|
|
|
47
|
-
sig { void }
|
|
48
68
|
def clear!
|
|
49
69
|
metadata.clear_maintenance_payload!
|
|
50
70
|
end
|
|
51
71
|
|
|
52
72
|
private
|
|
53
73
|
|
|
54
|
-
sig { params(current: T.nilable(Pending), delta: Pending).returns(Pending) }
|
|
55
74
|
def combine(current, delta)
|
|
56
75
|
return delta if current.nil?
|
|
57
76
|
return current if recompute_all?(current) # terminal: absorb everything
|
|
77
|
+
# Different kinds can't merge (a summary delta meeting a scoped recompute — e.g.
|
|
78
|
+
# reconcile's repair racing a callback write); widen to a full recompute rather
|
|
79
|
+
# than dropping either side's pending maintenance.
|
|
80
|
+
return MaintenanceDelta.full_partition unless current.instance_of?(delta.class)
|
|
58
81
|
|
|
59
|
-
merged = current.
|
|
82
|
+
merged = current.merge(delta)
|
|
60
83
|
oversized?(merged) ? MaintenanceDelta.full_partition : merged
|
|
61
84
|
end
|
|
62
85
|
|
|
63
|
-
sig { params(pending: T.nilable(Pending)).returns(T::Boolean) }
|
|
64
86
|
def recompute_all?(pending)
|
|
65
87
|
pending.is_a?(MaintenanceDelta) && pending.full_partition?
|
|
66
88
|
end
|
|
67
89
|
|
|
68
|
-
|
|
90
|
+
# A cold view can't apply a full-partition recompute (Refresher#maintainable? skips it) and storing
|
|
91
|
+
# one gums up the payload: combine's terminal absorb (see #recompute_all?) then swallows every later
|
|
92
|
+
# scoped read-miss delta, so populate-on-read never repopulates. Every cold widen funnels through
|
|
93
|
+
# merge! — an ingested widening write, DependencyRegistry.mark_dirty_for_tables!, or a scoped payload
|
|
94
|
+
# that overflows max_tracked_partitions inside #combine — so this one guard covers them all. The
|
|
95
|
+
# cheap in-memory recompute_all? short-circuits before materialized?, so the scoped hot path is free.
|
|
96
|
+
def cold_full_recompute?(combined)
|
|
97
|
+
recompute_all?(combined) && !view_class.materialized?
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Drop the un-appliable recompute and invalidate the whole fresh set so every partition falls
|
|
101
|
+
# through to the source until a read-miss repopulates it. Reset first, then clear: a crash between
|
|
102
|
+
# the two leaves reads correct-by-fallback with only an un-appliable payload the next merge! re-drops.
|
|
103
|
+
def drop_cold_full_recompute!
|
|
104
|
+
PartitionState.new(view_class).reset!
|
|
105
|
+
clear!
|
|
106
|
+
end
|
|
107
|
+
|
|
69
108
|
def oversized?(merged)
|
|
70
109
|
merged.tracked_partition_count > ActiveRecord::Materialized.configuration.max_tracked_partitions
|
|
71
110
|
end
|
|
72
111
|
|
|
73
|
-
sig { returns(ViewClass) }
|
|
74
112
|
attr_reader :view_class
|
|
75
113
|
|
|
76
|
-
sig { returns(Metadata) }
|
|
77
114
|
def metadata
|
|
78
115
|
view_class.metadata
|
|
79
116
|
end
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
# typed: strict
|
|
2
1
|
# frozen_string_literal: true
|
|
3
2
|
|
|
4
3
|
module ActiveRecord
|
|
@@ -8,22 +7,17 @@ module ActiveRecord
|
|
|
8
7
|
#
|
|
9
8
|
# @api private
|
|
10
9
|
module MaintenancePayload
|
|
11
|
-
extend T::Sig
|
|
12
|
-
|
|
13
|
-
sig { params(metadata: Metadata, payload: T::Hash[String, T.untyped]).void }
|
|
14
10
|
def self.record!(metadata, payload)
|
|
15
11
|
metadata.record.update!(maintenance_payload: payload.to_json)
|
|
16
12
|
end
|
|
17
13
|
|
|
18
|
-
sig { params(metadata: Metadata).returns(T.nilable(T::Hash[String, T.untyped])) }
|
|
19
14
|
def self.fetch(metadata)
|
|
20
|
-
raw =
|
|
15
|
+
raw = metadata.record.maintenance_payload
|
|
21
16
|
return nil if raw.blank?
|
|
22
17
|
|
|
23
18
|
JSON.parse(raw)
|
|
24
19
|
end
|
|
25
20
|
|
|
26
|
-
sig { params(metadata: Metadata).void }
|
|
27
21
|
def self.clear!(metadata)
|
|
28
22
|
metadata.record.update!(maintenance_payload: nil)
|
|
29
23
|
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveRecord
|
|
4
|
+
module Materialized
|
|
5
|
+
class Metadata
|
|
6
|
+
# Writes a view's reconciliation bookkeeping — +last_reconciled_at+ and
|
|
7
|
+
# +reconciled_partition_count+ — on the metadata row. Kept out of {Metadata}
|
|
8
|
+
# itself the way {MaintenancePayload} is; reads go through the metadata record.
|
|
9
|
+
#
|
|
10
|
+
# @api private
|
|
11
|
+
module Reconciliation
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# Stamp a completed reconciliation pass, resetting the staleness clock even
|
|
15
|
+
# when no partition needed repair.
|
|
16
|
+
def mark!(metadata, repaired_partition_count:)
|
|
17
|
+
metadata.record.update!(
|
|
18
|
+
last_reconciled_at: Timestamps.current,
|
|
19
|
+
reconciled_partition_count: repaired_partition_count
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
# typed: strict
|
|
2
1
|
# frozen_string_literal: true
|
|
3
2
|
|
|
4
3
|
module ActiveRecord
|
|
@@ -8,23 +7,18 @@ module ActiveRecord
|
|
|
8
7
|
#
|
|
9
8
|
# @api private
|
|
10
9
|
module Schema
|
|
11
|
-
extend T::Sig
|
|
12
|
-
|
|
13
10
|
module_function
|
|
14
11
|
|
|
15
|
-
sig { params(view_class: ViewClass).void }
|
|
16
12
|
def ensure_table!(view_class)
|
|
17
13
|
connection = view_class.connection
|
|
18
14
|
create_metadata_table!(connection) unless MetadataRecord.table_exists?
|
|
19
|
-
|
|
20
|
-
ensure_maintenance_payload_column!(connection)
|
|
21
|
-
ensure_warm_column!(connection)
|
|
15
|
+
ensure_columns!(connection)
|
|
22
16
|
MetadataRecord.reset_column_information
|
|
23
17
|
end
|
|
24
18
|
|
|
25
|
-
sig { params(connection: Connection).void }
|
|
26
19
|
def create_metadata_table!(connection)
|
|
27
|
-
|
|
20
|
+
table = ::ActiveRecord::Materialized.metadata_table_name
|
|
21
|
+
connection.create_table(table, force: :cascade) do |t|
|
|
28
22
|
t.string :view_name, null: false
|
|
29
23
|
t.datetime :last_refreshed_at
|
|
30
24
|
t.boolean :refreshing, null: false, default: false
|
|
@@ -34,48 +28,32 @@ module ActiveRecord
|
|
|
34
28
|
t.integer :refresh_duration_ms
|
|
35
29
|
t.text :last_error
|
|
36
30
|
t.text :maintenance_payload
|
|
31
|
+
t.datetime :last_reconciled_at
|
|
32
|
+
t.integer :reconciled_partition_count
|
|
33
|
+
t.integer :fresh_set_generation, null: false, default: 0
|
|
37
34
|
t.timestamps
|
|
38
35
|
end
|
|
39
|
-
connection.add_index(
|
|
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
|
-
)
|
|
36
|
+
connection.add_index(table, :view_name, unique: true)
|
|
54
37
|
end
|
|
55
38
|
|
|
56
|
-
|
|
57
|
-
|
|
39
|
+
# Lazily add columns introduced after a table was first provisioned, so an app
|
|
40
|
+
# migrated from an earlier version picks them up without a new migration.
|
|
41
|
+
def ensure_columns!(connection)
|
|
58
42
|
return unless MetadataRecord.table_exists?
|
|
59
|
-
return if MetadataRecord.column_names.include?("maintenance_payload")
|
|
60
43
|
|
|
61
|
-
connection
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
)
|
|
44
|
+
ensure_column!(connection, :dirty, :boolean, default: true, null: false)
|
|
45
|
+
ensure_column!(connection, :warm, :boolean, default: false, null: false)
|
|
46
|
+
ensure_column!(connection, :maintenance_payload, :text)
|
|
47
|
+
ensure_column!(connection, :last_reconciled_at, :datetime)
|
|
48
|
+
ensure_column!(connection, :reconciled_partition_count, :integer)
|
|
49
|
+
ensure_column!(connection, :fresh_set_generation, :integer, default: 0, null: false)
|
|
66
50
|
end
|
|
67
51
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
return unless MetadataRecord.table_exists?
|
|
71
|
-
return if MetadataRecord.column_names.include?("warm")
|
|
52
|
+
def ensure_column!(connection, name, type, default: nil, null: true)
|
|
53
|
+
return if MetadataRecord.column_names.include?(name.to_s)
|
|
72
54
|
|
|
73
55
|
connection.add_column(
|
|
74
|
-
::ActiveRecord::Materialized.metadata_table_name,
|
|
75
|
-
:warm,
|
|
76
|
-
:boolean,
|
|
77
|
-
default: false,
|
|
78
|
-
null: false
|
|
56
|
+
::ActiveRecord::Materialized.metadata_table_name, name, type, default: default, null: null
|
|
79
57
|
)
|
|
80
58
|
end
|
|
81
59
|
end
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
# typed: strict
|
|
2
1
|
# frozen_string_literal: true
|
|
3
2
|
|
|
4
3
|
module ActiveRecord
|
|
@@ -8,16 +7,12 @@ module ActiveRecord
|
|
|
8
7
|
#
|
|
9
8
|
# @api private
|
|
10
9
|
module Timestamps
|
|
11
|
-
extend T::Sig
|
|
12
|
-
|
|
13
10
|
module_function
|
|
14
11
|
|
|
15
|
-
sig { returns(Timestamp) }
|
|
16
12
|
def current
|
|
17
13
|
::Time.zone&.now || ::Time.now.utc
|
|
18
14
|
end
|
|
19
15
|
|
|
20
|
-
sig { params(staleness: StalenessDuration).returns(Timestamp) }
|
|
21
16
|
def threshold(staleness)
|
|
22
17
|
if staleness.is_a?(Integer)
|
|
23
18
|
::ActiveSupport::Duration.seconds(staleness).ago
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
# typed: strict
|
|
2
1
|
# frozen_string_literal: true
|
|
3
2
|
|
|
4
3
|
require_relative "metadata/schema"
|
|
5
4
|
require_relative "metadata/maintenance_payload"
|
|
5
|
+
require_relative "metadata/reconciliation"
|
|
6
6
|
require_relative "metadata/timestamps"
|
|
7
7
|
|
|
8
8
|
module ActiveRecord
|
|
@@ -11,27 +11,21 @@ module ActiveRecord
|
|
|
11
11
|
#
|
|
12
12
|
# @api private
|
|
13
13
|
class Metadata
|
|
14
|
-
extend T::Sig
|
|
15
|
-
|
|
16
|
-
sig { returns(ViewClass) }
|
|
17
14
|
attr_reader :view_class
|
|
18
15
|
|
|
19
|
-
sig { params(view_class: ViewClass).void }
|
|
20
16
|
def initialize(view_class)
|
|
21
17
|
@view_class = view_class
|
|
22
|
-
@schema_ensured =
|
|
18
|
+
@schema_ensured = false
|
|
23
19
|
end
|
|
24
20
|
|
|
25
21
|
# Single entry point for the metadata row. Provision the schema once per
|
|
26
22
|
# instance — re-ensuring on every access thrashes the schema cache and
|
|
27
23
|
# makes high-write workloads quadratic.
|
|
28
|
-
sig { returns(MetadataRecord) }
|
|
29
24
|
def record
|
|
30
25
|
ensure_schema!
|
|
31
26
|
MetadataRecord.find_or_initialize_by(view_name: view_class.view_key)
|
|
32
27
|
end
|
|
33
28
|
|
|
34
|
-
sig { void }
|
|
35
29
|
def ensure_schema!
|
|
36
30
|
return if @schema_ensured
|
|
37
31
|
|
|
@@ -39,73 +33,86 @@ module ActiveRecord
|
|
|
39
33
|
@schema_ensured = true
|
|
40
34
|
end
|
|
41
35
|
|
|
42
|
-
sig { returns(T.nilable(Timestamp)) }
|
|
43
36
|
def last_refreshed_at
|
|
44
37
|
record.last_refreshed_at
|
|
45
38
|
end
|
|
46
39
|
|
|
47
|
-
sig { returns(T::Boolean) }
|
|
48
40
|
def refreshing?
|
|
49
41
|
!!record.refreshing?
|
|
50
42
|
end
|
|
51
43
|
|
|
52
|
-
sig { returns(T.nilable(Integer)) }
|
|
53
44
|
def row_count
|
|
54
45
|
record.row_count
|
|
55
46
|
end
|
|
56
47
|
|
|
57
|
-
sig { returns(T.nilable(Integer)) }
|
|
58
48
|
def refresh_duration_ms
|
|
59
49
|
record.refresh_duration_ms
|
|
60
50
|
end
|
|
61
51
|
|
|
62
|
-
sig { returns(T::Boolean) }
|
|
63
52
|
def dirty?
|
|
64
53
|
!!record.dirty?
|
|
65
54
|
end
|
|
66
55
|
|
|
67
56
|
# Warm once fully materialized via rebuild!; cold views read through.
|
|
68
|
-
sig { returns(T::Boolean) }
|
|
69
57
|
def warm?
|
|
70
58
|
!!record.warm?
|
|
71
59
|
end
|
|
72
60
|
|
|
73
|
-
sig { void }
|
|
74
61
|
def mark_warm!
|
|
75
62
|
record.update!(warm: true)
|
|
76
63
|
end
|
|
77
64
|
|
|
78
|
-
|
|
65
|
+
# The current fresh-set epoch for this view (see PartitionState). A populate captures it before
|
|
66
|
+
# its source read; all_fresh? honours only marks stamped with the current value. (#120)
|
|
67
|
+
def fresh_set_generation
|
|
68
|
+
record.fresh_set_generation
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Advance the fresh-set epoch atomically so every mark stamped with an earlier value is treated
|
|
72
|
+
# as stale — used by PartitionState#reset! to invalidate a cold view's whole fresh set on a widen.
|
|
73
|
+
# Ensures the row exists first (create_or_find_by absorbs a concurrent first insert), then a single
|
|
74
|
+
# atomic SQL increment (never a read-then-write), so two concurrent resets can't lost-update the
|
|
75
|
+
# epoch — a populate that captured any earlier value still fails all_fresh?. Portable across adapters.
|
|
76
|
+
def bump_fresh_set_generation!
|
|
77
|
+
ensure_schema!
|
|
78
|
+
MetadataRecord.create_or_find_by(view_name: view_class.view_key)
|
|
79
|
+
connection = view_class.connection
|
|
80
|
+
table = connection.quote_table_name(::ActiveRecord::Materialized.metadata_table_name)
|
|
81
|
+
connection.execute(
|
|
82
|
+
"UPDATE #{table} SET fresh_set_generation = fresh_set_generation + 1 " \
|
|
83
|
+
"WHERE view_name = #{connection.quote(view_class.view_key)}"
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
|
|
79
87
|
def stale?(max_staleness: view_class.resolved_max_staleness)
|
|
80
88
|
return true if dirty?
|
|
81
89
|
return true if last_refreshed_at.nil?
|
|
82
90
|
return false if max_staleness.nil?
|
|
83
91
|
|
|
84
|
-
|
|
85
|
-
|
|
92
|
+
# A reconcile verifies contents against the source, so it resets the staleness clock like a
|
|
93
|
+
# refresh — measure age from whichever happened later. The replica-lag budget tightens the
|
|
94
|
+
# window: a replica read trails the primary by the lag, so the view goes stale that much
|
|
95
|
+
# sooner to keep replica reads within max_staleness.
|
|
96
|
+
freshest = [last_refreshed_at, record.last_reconciled_at].compact.max_by(&:to_time)
|
|
97
|
+
freshest.to_time < Timestamps.threshold(effective_staleness(max_staleness)).to_time
|
|
86
98
|
end
|
|
87
99
|
|
|
88
|
-
sig { void }
|
|
89
100
|
def mark_dirty!
|
|
90
101
|
record.update!(dirty: true)
|
|
91
102
|
end
|
|
92
103
|
|
|
93
|
-
sig { params(payload: T::Hash[String, T.untyped]).void }
|
|
94
104
|
def record_maintenance_payload!(payload)
|
|
95
105
|
MaintenancePayload.record!(self, payload)
|
|
96
106
|
end
|
|
97
107
|
|
|
98
|
-
sig { returns(T.nilable(T::Hash[String, T.untyped])) }
|
|
99
108
|
def maintenance_payload
|
|
100
109
|
MaintenancePayload.fetch(self)
|
|
101
110
|
end
|
|
102
111
|
|
|
103
|
-
sig { void }
|
|
104
112
|
def clear_maintenance_payload!
|
|
105
113
|
MaintenancePayload.clear!(self)
|
|
106
114
|
end
|
|
107
115
|
|
|
108
|
-
sig { void }
|
|
109
116
|
def mark_refreshing!
|
|
110
117
|
record.update!(
|
|
111
118
|
refreshing: true,
|
|
@@ -113,7 +120,6 @@ module ActiveRecord
|
|
|
113
120
|
)
|
|
114
121
|
end
|
|
115
122
|
|
|
116
|
-
sig { params(row_count: Integer, duration_ms: Integer).void }
|
|
117
123
|
def mark_refreshed!(row_count:, duration_ms:)
|
|
118
124
|
record.update!(
|
|
119
125
|
last_refreshed_at: Timestamps.current,
|
|
@@ -126,13 +132,23 @@ module ActiveRecord
|
|
|
126
132
|
)
|
|
127
133
|
end
|
|
128
134
|
|
|
129
|
-
sig { params(error: StandardError).void }
|
|
130
135
|
def mark_failed!(error)
|
|
131
136
|
record.update!(
|
|
132
137
|
refreshing: false,
|
|
133
138
|
last_error: error.message
|
|
134
139
|
)
|
|
135
140
|
end
|
|
141
|
+
|
|
142
|
+
private
|
|
143
|
+
|
|
144
|
+
# max_staleness minus the configured replica-lag budget (clamped at 0), so replica reads stay
|
|
145
|
+
# within the window. Zero lag (the default) leaves max_staleness unchanged.
|
|
146
|
+
def effective_staleness(max_staleness)
|
|
147
|
+
lag = ActiveRecord::Materialized.configuration.replica_lag
|
|
148
|
+
return max_staleness if lag.nil? || lag.zero?
|
|
149
|
+
|
|
150
|
+
[max_staleness - lag, 0].max
|
|
151
|
+
end
|
|
136
152
|
end
|
|
137
153
|
end
|
|
138
154
|
end
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
# typed: strict
|
|
2
1
|
# frozen_string_literal: true
|
|
3
2
|
|
|
4
3
|
module ActiveRecord
|
|
@@ -7,22 +6,9 @@ module ActiveRecord
|
|
|
7
6
|
#
|
|
8
7
|
# @api private
|
|
9
8
|
class MetadataRecord < ::ActiveRecord::Base
|
|
10
|
-
|
|
9
|
+
include ConfigurableTableName
|
|
11
10
|
|
|
12
|
-
|
|
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
|
|
11
|
+
configurable_table_name { ::ActiveRecord::Materialized.metadata_table_name }
|
|
26
12
|
end
|
|
27
13
|
end
|
|
28
14
|
end
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
# typed: strict
|
|
2
1
|
# frozen_string_literal: true
|
|
3
2
|
|
|
4
3
|
module ActiveRecord
|
|
@@ -7,32 +6,33 @@ module ActiveRecord
|
|
|
7
6
|
# table: table name, migration class name, target version, and inferred
|
|
8
7
|
# columns/types. The file itself is produced by Rails' generator tooling.
|
|
9
8
|
class MigrationBuilder
|
|
10
|
-
extend T::Sig
|
|
11
|
-
|
|
12
|
-
sig { params(view_class: ViewClass).void }
|
|
13
9
|
def initialize(view_class)
|
|
14
10
|
@view_class = view_class
|
|
15
11
|
end
|
|
16
12
|
|
|
17
|
-
sig { returns(String) }
|
|
18
13
|
def table_name
|
|
19
14
|
@view_class.table_name
|
|
20
15
|
end
|
|
21
16
|
|
|
22
|
-
sig { returns(String) }
|
|
23
17
|
def migration_class_name
|
|
24
18
|
"Create#{table_name.camelize}"
|
|
25
19
|
end
|
|
26
20
|
|
|
27
|
-
sig { returns(T.any(String, Float)) }
|
|
28
21
|
def migration_version
|
|
29
22
|
::ActiveRecord::Migration.current_version
|
|
30
23
|
end
|
|
31
24
|
|
|
32
|
-
sig { returns(T::Array[CacheTableSchema::ColumnDefinition]) }
|
|
33
25
|
def column_definitions
|
|
34
26
|
CacheTableSchema.column_definitions(@view_class.connection, @view_class.resolved_source)
|
|
35
27
|
end
|
|
28
|
+
|
|
29
|
+
# The partition-key index the migration should add (nil for a non-grouped view). Incremental
|
|
30
|
+
# maintenance is keyed on these columns, so the generated migration indexes them by default.
|
|
31
|
+
#
|
|
32
|
+
# @return [CacheTableSchema::IndexDefinition, nil]
|
|
33
|
+
def index_definition
|
|
34
|
+
CacheTableSchema.index_definition(@view_class)
|
|
35
|
+
end
|
|
36
36
|
end
|
|
37
37
|
end
|
|
38
38
|
end
|