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
@@ -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
- metadata.record_maintenance_payload!(T.unsafe(combine(pending, delta)).serialize)
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
- sig { returns(MaintenanceDelta) }
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
- delta = pending_delta || MaintenanceDelta.full_partition
43
- clear!
44
- delta
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.instance_of?(delta.class) ? T.unsafe(current).merge(delta) : delta
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
- sig { params(merged: Pending).returns(T::Boolean) }
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 = T.unsafe(metadata.record).maintenance_payload
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
- ensure_dirty_column!(connection)
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
- connection.create_table(::ActiveRecord::Materialized.metadata_table_name, force: :cascade) do |t|
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(::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
- )
36
+ connection.add_index(table, :view_name, unique: true)
54
37
  end
55
38
 
56
- sig { params(connection: Connection).void }
57
- def ensure_maintenance_payload_column!(connection)
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.add_column(
62
- ::ActiveRecord::Materialized.metadata_table_name,
63
- :maintenance_payload,
64
- :text
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
- 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")
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 = T.let(false, T::Boolean)
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
- sig { params(max_staleness: T.nilable(StalenessDuration)).returns(T::Boolean) }
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
- refreshed_at = T.must(last_refreshed_at)
85
- refreshed_at.to_time < Timestamps.threshold(max_staleness).to_time
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
- extend T::Sig
9
+ include ConfigurableTableName
11
10
 
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
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