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.
Files changed (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +93 -0
  3. data/README.md +138 -323
  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 +27 -18
  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
@@ -13,19 +12,16 @@ module ActiveRecord
13
12
  # @see Materialized::Configuration the configurable settings
14
13
  module Materialized
15
14
  class << self
16
- extend T::Sig
17
-
18
- @configuration = T.let(nil, T.nilable(Configuration))
15
+ @configuration = nil
19
16
 
20
17
  # The global configuration object. Prefer {configure} for setting values.
21
18
  #
22
19
  # @return [Configuration] the current configuration (created on first use)
23
- sig { returns(Configuration) }
24
20
  def configuration
25
21
  config = @configuration
26
22
  if config.nil?
27
23
  config = Configuration.new
28
- @configuration = T.let(config, T.nilable(Configuration))
24
+ @configuration = config
29
25
  end
30
26
  config
31
27
  end
@@ -41,20 +37,13 @@ module ActiveRecord
41
37
  #
42
38
  # @yieldparam config [Configuration]
43
39
  # @return [void]
44
- sig { params(block: T.proc.params(config: Configuration).void).void }
45
40
  def configure(&block)
46
41
  yield(configuration)
47
42
  end
48
43
 
49
- sig { returns(String) }
50
- def metadata_table_name
51
- configuration.metadata_table_name
52
- end
44
+ def metadata_table_name = configuration.metadata_table_name
53
45
 
54
- sig { returns(String) }
55
- def partition_table_name
56
- configuration.partition_table_name
57
- end
46
+ def partition_table_name = configuration.partition_table_name
58
47
 
59
48
  # Verifies every registered view's cache table still matches the columns its
60
49
  # source relation projects — run it at boot or in CI to catch a view whose
@@ -62,21 +51,229 @@ module ActiveRecord
62
51
  #
63
52
  # @raise [SchemaVerifier::SchemaDriftError] on the first drifted view
64
53
  # @return [void]
65
- sig { void }
66
54
  def verify_schema!
67
55
  registered = Registry.all
68
56
  registered.each { |view_class| SchemaVerifier.new(view_class).verify! }
69
57
  end
70
58
 
71
- sig { returns(T::Boolean) }
72
- def atomic_swap_refresh?
73
- configuration.atomic_swap_refresh
59
+ # Checks every registered view's materialized *contents* against its source
60
+ # relation and returns a {DataVerificationResult} per view (never raises on
61
+ # drift), so callers can alert on or repair the divergent partition keys.
62
+ #
63
+ # @param mode [Symbol] +:row_count+, +:checksum+, or +:full+
64
+ # @param sample [Numeric, nil] verify a random subset (Integer count / Float fraction)
65
+ # @return [Array<DataVerificationResult>]
66
+ def verify_data(mode: :checksum, sample: nil)
67
+ Registry.all.map { |view_class| DataVerifier.new(view_class, mode: mode, sample: sample).verify }
68
+ end
69
+
70
+ # Like {verify_data} but raises {DataVerifier::DataDriftError} if any view has
71
+ # drifted — for a boot/CI/cron gate. Returns the results when all are clean.
72
+ #
73
+ # @raise [DataVerifier::DataDriftError] listing the drifted views
74
+ # @return [Array<DataVerificationResult>]
75
+ def verify_data!(mode: :checksum, sample: nil)
76
+ results = verify_data(mode: mode, sample: sample)
77
+ drifted = results.select(&:drifted?)
78
+ return results if drifted.empty?
79
+
80
+ raise DataVerifier::DataDriftError, data_drift_message(drifted)
81
+ end
82
+
83
+ # Reconciles every registered view — verifies its contents against the source and
84
+ # repairs any drift with scoped maintenance (never a full rebuild), returning a
85
+ # {ReconcileResult} per view. See {Reconciler}.
86
+ #
87
+ # @param mode [Symbol] drift-check depth: +:row_count+, +:checksum+, or +:full+
88
+ # @param sample [Numeric, nil] verify a random subset (Integer count / Float fraction)
89
+ # @return [Array<ReconcileResult>]
90
+ def reconcile!(mode: :checksum, sample: nil)
91
+ Registry.reconcile_all!(mode: mode, sample: sample)
92
+ end
93
+
94
+ # Like {reconcile!} but only for stale views — dirty, never refreshed, or past
95
+ # +max_staleness+ — the scheduled bounded-staleness backstop (fresh views are
96
+ # skipped). Drive it from cron or ActiveJob.
97
+ #
98
+ # @param mode [Symbol] drift-check depth: +:row_count+, +:checksum+, or +:full+
99
+ # @param sample [Numeric, nil] verify a random subset (Integer count / Float fraction)
100
+ # @return [Array<ReconcileResult>] one per reconciled (stale) view
101
+ def reconcile_stale!(mode: :checksum, sample: nil)
102
+ Registry.reconcile_stale!(mode: mode, sample: sample)
74
103
  end
75
104
 
76
- sig { params(value: Configuration).void }
77
- def configuration=(value)
78
- @configuration = T.let(value, T.nilable(Configuration))
105
+ # Fans the periodic reconcile backstop across an ActiveJob fleet: enqueues one
106
+ # {ReconcileJob} per stale, materialized view so many workers share the (expensive) drift
107
+ # verification instead of one process running it serially. Run it from a SINGLE owner —
108
+ # a leader, a dedicated instance, or one recurring job — never as cron on every server (see
109
+ # the distributed-deployment guide). Requires ActiveJob; without it, use {reconcile_stale!}.
110
+ #
111
+ # @param mode [Symbol] drift-check depth passed to each job (+:row_count+/+:checksum+/+:full+)
112
+ # @param sample [Numeric, nil] verify a random subset (Integer count / Float fraction)
113
+ # @return [Array<String>] the view keys enqueued
114
+ def enqueue_stale_reconciles!(mode: :checksum, sample: nil)
115
+ enqueue_for_each_stale_view(:enqueue_stale_reconciles!) do |view_class|
116
+ ReconcileJob.perform_later(view_class.view_key, mode: mode, sample: sample)
117
+ end
118
+ end
119
+
120
+ # The fan-out form of the serial {Registry.refresh_stale!}: enqueues one {RefreshJob} per
121
+ # stale, materialized view. Same single-owner rule as {enqueue_stale_reconciles!}. Requires ActiveJob.
122
+ #
123
+ # @return [Array<String>] the view keys enqueued
124
+ def enqueue_stale_refreshes!
125
+ enqueue_for_each_stale_view(:enqueue_stale_refreshes!) do |view_class|
126
+ RefreshJob.perform_later(view_class.view_key)
127
+ end
128
+ end
129
+
130
+ # Logged once at boot (see {Railtie}) when ActiveJob is available but the effective dispatcher
131
+ # is still the in-process refresher — it does not coordinate across processes, so it is a
132
+ # correctness/efficiency hazard in a multi-process deployment. Silent when dispatching via
133
+ # ActiveJob, and silent when ActiveJob isn't loaded at all (a genuinely single-process app,
134
+ # for which the in-process refresher is the right and only choice).
135
+ #
136
+ # @param logger [#warn, nil] destination (defaults to the Rails logger)
137
+ # @return [void]
138
+ def warn_if_in_process_dispatcher!(logger: default_logger)
139
+ return unless configuration.active_job_available?
140
+ return if configuration.refresh_dispatcher == :active_job
141
+
142
+ logger&.warn(IN_PROCESS_DISPATCHER_WARNING)
143
+ end
144
+
145
+ # Publishes a committed dependency write from a custom change source (a
146
+ # CDC/replication stream, a bulk loader, another service). It drives the
147
+ # externally-fed views (`change_source :none`) that depend on the table;
148
+ # callback-driven views are left to their own commit callbacks so no view is
149
+ # maintained twice. To recover a callback-driven view after a callback-skipping
150
+ # bulk load, use {mark_dirty_for_tables!} instead.
151
+ #
152
+ # @param change [WriteChange] the committed write to publish
153
+ # @return [void]
154
+ def publish_write_change!(change)
155
+ DependencyRegistry.publish_write_change!(change)
156
+ end
157
+
158
+ # Coarse ingestion signal for callers that cannot describe the individual
159
+ # write: enqueues a full recompute for every view depending on any of
160
+ # +tables+ and schedules it per each view's refresh strategy (inline for
161
+ # +:immediate+, in the background for +:async+). Idempotent, so it is safe to
162
+ # call repeatedly and to recover a view after a callback-skipping bulk load.
163
+ #
164
+ # @param tables [Array<String>] dependency table names
165
+ # @return [void]
166
+ def mark_dirty_for_tables!(tables)
167
+ DependencyRegistry.mark_dirty_for_tables!(tables)
168
+ end
169
+
170
+ # Ingests a change described as a normalized descriptor — the CDC-friendly
171
+ # entry point for a consumer that has the table, operation, and (optionally)
172
+ # the changed key columns or full before/after images as plain data rather
173
+ # than an ActiveRecord object. Builds a {WriteChange} and publishes it via
174
+ # {publish_write_change!}, so it drives the externally-fed
175
+ # (+change_source :none+) views on the table; a callback-driven view is left
176
+ # to its own callbacks — recover one after a bulk load with
177
+ # {mark_dirty_for_tables!}.
178
+ #
179
+ # Supply +before+/+after+ images when the stream carries them; otherwise
180
+ # +key_attributes+ scopes maintenance to the affected partition(s). With
181
+ # neither — or a partial +:update+ image that cannot identify both the old
182
+ # and new partition — maintenance widens to a full recompute (always correct).
183
+ # See {WriteChange.from_descriptor}.
184
+ #
185
+ # @param table [String] the changed table
186
+ # @param operation [Symbol] +:create+, +:update+, or +:destroy+
187
+ # @param key_attributes [Hash, nil] the GROUP BY key columns of the changed row
188
+ # @param before [Hash, nil] pre-image, for +:update+/+:destroy+
189
+ # @param after [Hash, nil] post-image, for +:create+/+:update+
190
+ # @param source_ts [Integer, nil] optional monotonic source watermark (e.g. a Debezium +source.ts_ms+
191
+ # or a per-key Kafka offset). When given, an affected partition that already applied a strictly-newer
192
+ # value is skipped as provably-stale — an equal value still applies, so a coarse (second-granular)
193
+ # timestamp is safe (best-effort, reconcile-backed) — and the view's freshness/lag becomes
194
+ # observable via {SourceWatermark}. Ignored for callback-driven and full-recompute writes.
195
+ # @param images [Hash] the change images/keys — +key_attributes:+, +before:+, +after:+ (forwarded
196
+ # to {WriteChange.from_descriptor}, which rejects any unrecognized keyword)
197
+ # @return [void]
198
+ def ingest_change(table:, operation:, source_ts: nil, **images)
199
+ unless source_ts.nil? || source_ts.is_a?(Integer)
200
+ raise ArgumentError,
201
+ "source_ts must be an Integer (e.g. a Debezium ts_ms or a Kafka offset); got #{source_ts.class}"
202
+ end
203
+
204
+ change = WriteChange.from_descriptor(table_name: table, operation: operation, **images)
205
+ change = change.with_source_ts(source_ts) if source_ts
206
+ publish_write_change!(change)
207
+ end
208
+
209
+ # Relay one Debezium CDC change envelope through {#ingest_change} — mapping +op+ (c/r → create,
210
+ # u → update, d → destroy), the +before+/+after+ row images, +source.table+, and the envelope's
211
+ # +source.ts_ms+ as the +source_ts+ watermark for you (a nested
212
+ # +payload+ is unwrapped). A +nil+ envelope (a Kafka tombstone) is a no-op; a non-tombstone with
213
+ # no +op+, or an unsupported +op+, raises. Pass +table+ to override the target when it differs
214
+ # from +source.table+. +table+ is positional (not a keyword) so an inline envelope literal is not
215
+ # misparsed as keyword arguments. See {DebeziumEnvelope} and the CDC section of the README.
216
+ #
217
+ # @param envelope [Hash, nil] a decoded Debezium change event value (string or symbol keys)
218
+ # @param table [String, Symbol, nil] target-table override; defaults to +source.table+
219
+ # @return [void]
220
+ # @raise [ArgumentError] on a mis-shaped envelope, an unsupported +op+, or an undeterminable table
221
+ def ingest_debezium_change(envelope, table = nil)
222
+ descriptor = DebeziumEnvelope.to_change_descriptor(envelope, table)
223
+ ingest_change(**descriptor) if descriptor
224
+ end
225
+
226
+ # Relay pending {WriteOutbox} rows (captured by database triggers for out-of-band writes)
227
+ # through {#ingest_change}, then delete them. The operational entry point run from a poller,
228
+ # cron, or background job when a table has trigger/outbox capture installed. Returns the number
229
+ # of rows relayed so a caller can loop until drained. See +docs/out-of-band-writes.md+.
230
+ #
231
+ # @param limit [Integer, nil] max rows to relay this pass (nil = all pending)
232
+ # @return [Integer]
233
+ def drain_write_outbox!(limit: nil) = WriteOutbox.drain!(limit: limit)
234
+
235
+ def atomic_swap_refresh? = configuration.atomic_swap_refresh
236
+
237
+ attr_writer :configuration
238
+
239
+ private
240
+
241
+ # Enqueues one job per stale, *materialized* view and returns their keys. Cold views read
242
+ # through and aren't maintained, so a job for one would only no-op every tick — skip them.
243
+ def enqueue_for_each_stale_view(method_name)
244
+ require_active_job!(method_name)
245
+ Registry.stale_views.select(&:materialized?).map do |view_class|
246
+ yield view_class
247
+ view_class.view_key
248
+ end
249
+ end
250
+
251
+ def require_active_job!(method_name)
252
+ return if configuration.active_job_available?
253
+
254
+ raise NotImplementedError,
255
+ "#{method_name} requires ActiveJob — load it and set config.refresh_dispatcher = " \
256
+ ":active_job, or run the in-process reconcile_stale! / Registry.refresh_stale! instead."
257
+ end
258
+
259
+ def default_logger
260
+ Rails.logger if defined?(Rails) && Rails.respond_to?(:logger)
261
+ end
262
+
263
+ def data_drift_message(results)
264
+ summary = results.map do |result|
265
+ "#{result.view_name} (#{result.missing_keys.size} missing, " \
266
+ "#{result.extra_keys.size} extra, #{result.mismatched_keys.size} mismatched)"
267
+ end
268
+ "materialized view data drift detected: #{summary.join('; ')}"
79
269
  end
80
270
  end
271
+
272
+ # Guidance logged when the in-process dispatcher is active in a possibly multi-process
273
+ # deployment. Private — internal wording, not a public contract.
274
+ IN_PROCESS_DISPATCHER_WARNING =
275
+ "[activerecord-materialized] refresh_dispatcher is :async: the in-process refresher is " \
276
+ "single-process-only (queue lost on restart). Use config.refresh_dispatcher = :active_job for multiple servers."
277
+ private_constant :IN_PROCESS_DISPATCHER_WARNING
81
278
  end
82
279
  end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module Materialized
5
+ # Restricts a relation to a set of partition key tuples, given the Arel key
6
+ # attributes already resolved to their tables. Handles single- and multi-column
7
+ # keys, and matches a NULL key with IS NULL (SQL's `IN (...)` never matches NULL).
8
+ #
9
+ # @api private
10
+ class PartitionFilter
11
+ def initialize(attributes, key_tuples)
12
+ @attributes = attributes
13
+ @key_tuples = key_tuples
14
+ end
15
+
16
+ def apply(scope)
17
+ @attributes.size > 1 ? multi_column(scope) : scope.where(single_column_predicate)
18
+ end
19
+
20
+ private
21
+
22
+ # OR in an `IS NULL` when a NULL partition key is requested (a nullable GROUP
23
+ # BY column's NULL group), which `IN (...)` would otherwise skip.
24
+ def single_column_predicate
25
+ attribute = @attributes.fetch(0)
26
+ values = @key_tuples.map(&:first)
27
+ present = values.compact
28
+ in_predicate = attribute.in(present) unless present.empty?
29
+ return in_predicate unless values.size > present.size
30
+
31
+ null_predicate = attribute.eq(nil)
32
+ in_predicate ? in_predicate.or(null_predicate) : null_predicate
33
+ end
34
+
35
+ def multi_column(scope)
36
+ @key_tuples.reduce(nil) do |merged_scope, tuple|
37
+ branch = @attributes.each_with_index.reduce(scope) do |relation, (attribute, index)|
38
+ relation.where(attribute.eq(tuple[index]))
39
+ end
40
+ merged_scope.nil? ? branch : merged_scope.or(branch)
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module Materialized
5
+ # Shared base for the per-partition stores keyed by (view_name, partition_key): {PartitionState}
6
+ # (the cold-view fresh set) and {SourceWatermark} (the CDC watermark). Centralizes the primitives
7
+ # they must share — most importantly {#serialize}, which has to be identical across every store, or
8
+ # a mark and a watermark would key the same partition differently and lookups would silently miss.
9
+ #
10
+ # A subclass supplies the three things that differ — its {#record_class}, {#table_name}, and the
11
+ # table-specific columns via {#define_columns} — and may override {#ensure_columns!} to lazily add
12
+ # a column introduced after the table was first provisioned.
13
+ #
14
+ # @api private
15
+ class PartitionKeyedStore
16
+ def initialize(view_class)
17
+ @view_class = view_class
18
+ end
19
+
20
+ private
21
+
22
+ attr_reader :view_class
23
+
24
+ # The ActiveRecord model backing this store (e.g. PartitionRecord). Subclasses must override.
25
+ def record_class
26
+ raise NotImplementedError, "#{self.class} must define #record_class"
27
+ end
28
+
29
+ # The store's table name. Subclasses must override.
30
+ def table_name
31
+ raise NotImplementedError, "#{self.class} must define #table_name"
32
+ end
33
+
34
+ # Add the store's own columns (beyond view_name/partition_key) to the create_table definition.
35
+ # Subclasses must override.
36
+ def define_columns(_table)
37
+ raise NotImplementedError, "#{self.class} must define #define_columns"
38
+ end
39
+
40
+ # Lazily add columns introduced after the table was first provisioned, so an app migrated from an
41
+ # earlier version picks them up without a new migration. Default: nothing extra to add.
42
+ def ensure_columns!(_connection); end
43
+
44
+ # Serialize a partition-key tuple to its stored form. MUST stay identical across every store, or
45
+ # two stores would key the same partition differently and their lookups would silently miss.
46
+ def serialize(key_tuple)
47
+ key_tuple.map(&:to_s).to_json
48
+ end
49
+
50
+ def view_key
51
+ view_class.view_key
52
+ end
53
+
54
+ def scope
55
+ record_class.where(view_name: view_key)
56
+ end
57
+
58
+ def ensure_table!
59
+ connection = view_class.connection
60
+ return ensure_columns!(connection) if record_class.table_exists?
61
+
62
+ connection.create_table(table_name) do |t|
63
+ t.string :view_name, null: false
64
+ t.string :partition_key, null: false
65
+ define_columns(t)
66
+ end
67
+ connection.add_index(table_name, %i[view_name partition_key], unique: true)
68
+ record_class.reset_column_information
69
+ end
70
+ end
71
+ end
72
+ end
@@ -1,4 +1,3 @@
1
- # typed: strict
2
1
  # frozen_string_literal: true
3
2
 
4
3
  module ActiveRecord
@@ -6,22 +5,9 @@ module ActiveRecord
6
5
  # One row per fresh partition of a cold view; presence means the partition is
7
6
  # materialized and current, absence means it is not.
8
7
  class PartitionRecord < ::ActiveRecord::Base
9
- extend T::Sig
8
+ include ConfigurableTableName
10
9
 
11
- @table_name_override = T.let(nil, T.nilable(String))
12
-
13
- self.table_name = ::ActiveRecord::Materialized.partition_table_name
14
-
15
- sig { params(name: String).void }
16
- def self.table_name=(name)
17
- @table_name_override = name
18
- end
19
-
20
- sig { returns(String) }
21
- def self.table_name
22
- override = @table_name_override
23
- override.nil? ? ::ActiveRecord::Materialized.partition_table_name : override
24
- end
10
+ configurable_table_name { ::ActiveRecord::Materialized.partition_table_name }
25
11
  end
26
12
  end
27
13
  end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module ActiveRecord
6
+ module Materialized
7
+ # Reads a relation (via ActiveRecord, not raw SQL) into
8
+ # { group-key tuple => value-signature tally } for {DataVerifier}.
9
+ #
10
+ # Both keys and values are cast through the *cache model's* own attribute types,
11
+ # so the cache and the recomputed source are compared in a single type system —
12
+ # an integer, string, date, or scaled-decimal column can't masquerade as drift
13
+ # just because the two sides read it through different type casts. A float
14
+ # aggregate is additionally canonicalized to a fixed significance, since an
15
+ # order-dependent SUM/AVG can differ from a re-aggregation in the last bit without
16
+ # any real drift (see {FLOAT_SIGNIFICANT_DIGITS}).
17
+ #
18
+ # Rows are tallied per key, so two cache rows for one partition are detected as a
19
+ # count difference rather than silently collapsed.
20
+ #
21
+ # @api private
22
+ class PartitionSnapshot
23
+ SEPARATOR = "\x1f" # ASCII unit separator between digested values
24
+
25
+ # Significant digits a float aggregate is rounded to before comparison. A SUM/AVG over a float
26
+ # column is order-dependent — the DB may re-aggregate base rows in a different order than the
27
+ # maintained value accumulated, so the two differ in the last bit (0.1+0.2+0.3 => 0.6000000000000001
28
+ # but 0.3+0.2+0.1 => 0.6). Rounding to a fixed significance absorbs that float noise so it does not
29
+ # read as data drift, while any real divergence (a missed write moves the value by far more than a
30
+ # ULP) is still caught. Integer/COUNT and fixed-scale decimal columns already compare exactly after
31
+ # the type cast, so this only touches floats. ~12 of a double's ~15-16 significant digits leaves a
32
+ # safe margin for accumulated rounding without masking a meaningful difference.
33
+ FLOAT_SIGNIFICANT_DIGITS = 12
34
+
35
+ def initialize(model, key_columns, value_columns, mode)
36
+ @model = model
37
+ @key_columns = key_columns
38
+ @value_columns = value_columns
39
+ @mode = mode
40
+ end
41
+
42
+ def of(relation)
43
+ relation.to_a
44
+ .group_by { |record| key_tuple(record) }
45
+ .transform_values { |records| records.map { |record| value_signature(record) }.tally }
46
+ end
47
+
48
+ private
49
+
50
+ def key_tuple(record)
51
+ @key_columns.map { |column| cast(column, record[column]) }
52
+ end
53
+
54
+ def value_signature(record)
55
+ return nil if @mode == :row_count
56
+
57
+ values = @value_columns.map { |column| cast(column, record[column]) }
58
+ @mode == :checksum ? Digest::MD5.hexdigest(values.map(&:inspect).join(SEPARATOR)) : values
59
+ end
60
+
61
+ # Cast through the cache model's declared type so the cache value and the source recompute (read
62
+ # through a different type system) normalize identically, then canonicalize a float to a fixed
63
+ # significance so order-dependent SUM/AVG rounding does not read as drift (see FLOAT_SIGNIFICANT_DIGITS).
64
+ def cast(column, value)
65
+ canonicalize_float(@model.type_for_attribute(column).cast(value))
66
+ end
67
+
68
+ # Round a float to FLOAT_SIGNIFICANT_DIGITS significant digits (relative, so it works at any
69
+ # magnitude); non-floats — integers, fixed-scale decimals, strings — pass through untouched.
70
+ def canonicalize_float(value)
71
+ return value unless value.is_a?(Float)
72
+ return value unless value.finite? && !value.zero?
73
+
74
+ magnitude = Math.log10(value.abs).floor
75
+ value.round(FLOAT_SIGNIFICANT_DIGITS - 1 - magnitude)
76
+ end
77
+ end
78
+ end
79
+ end
@@ -1,4 +1,3 @@
1
- # typed: strict
2
1
  # frozen_string_literal: true
3
2
 
4
3
  module ActiveRecord
@@ -6,36 +5,33 @@ module ActiveRecord
6
5
  # Tracks which partitions of a cold view have been materialized ("fresh") so
7
6
  # a read can decide whether a partition is served from the cache or read
8
7
  # through to the source. Warm views are fully materialized and ignore this.
9
- class PartitionState
10
- extend T::Sig
11
-
12
- KeyTuple = T.type_alias { T::Array[T.untyped] }
13
-
14
- sig { params(view_class: ViewClass).void }
15
- def initialize(view_class)
16
- @view_class = view_class
17
- end
18
-
19
- sig { params(key_tuples: T::Array[KeyTuple]).returns(T::Boolean) }
8
+ class PartitionState < PartitionKeyedStore
20
9
  def all_fresh?(key_tuples)
21
10
  return false if key_tuples.empty?
22
11
 
23
12
  ensure_table!
13
+ view_class.metadata.ensure_schema! # the epoch subquery reads the metadata table
24
14
  serialized = key_tuples.map { |tuple| serialize(tuple) }.uniq
25
- scope.where(partition_key: serialized).count == serialized.size
15
+ # Only marks stamped with the current epoch count as fresh — a mark left behind by a populate
16
+ # that raced a reset! (a widen invalidation) carries a superseded generation and is ignored.
17
+ # The epoch is a correlated subquery so this stays ONE round-trip on the keyed cold-read fast path.
18
+ fresh = scope.where(partition_key: serialized, generation: current_generation_scope)
19
+ fresh.count == serialized.size
26
20
  end
27
21
 
28
- sig { params(key_tuples: T::Array[KeyTuple]).void }
29
- def mark_fresh!(key_tuples)
22
+ # Stamp each partition fresh with +generation+ (the epoch the caller captured before its source
23
+ # read). Overwrites any existing mark's generation, so a re-populate after a reset! re-establishes
24
+ # freshness rather than leaving a stale-epoch row that all_fresh? would forever ignore.
25
+ def mark_fresh!(key_tuples, generation:)
30
26
  return if key_tuples.empty?
31
27
 
32
28
  ensure_table!
33
29
  key_tuples.uniq.each do |tuple|
34
- PartitionRecord.create_or_find_by(view_name: view_key, partition_key: serialize(tuple))
30
+ record = record_class.create_or_find_by(view_name: view_key, partition_key: serialize(tuple))
31
+ record.update!(generation: generation) unless record.generation == generation
35
32
  end
36
33
  end
37
34
 
38
- sig { params(key_tuples: T::Array[KeyTuple]).void }
39
35
  def mark_stale!(key_tuples)
40
36
  return if key_tuples.empty?
41
37
 
@@ -43,15 +39,27 @@ module ActiveRecord
43
39
  scope.where(partition_key: key_tuples.map { |tuple| serialize(tuple) }).delete_all
44
40
  end
45
41
 
46
- sig { void }
42
+ # Invalidate the whole fresh set (a widen recompute whose scope is unknown). Advance the epoch
43
+ # FIRST, then delete the marks: the epoch bump alone invalidates everything (all_fresh? honours
44
+ # only current-epoch marks), so the delete is pure cleanup. Ordering it this way makes reset!
45
+ # crash-safe without a transaction — a crash after the bump but before the delete still leaves the
46
+ # fresh set invalidated (stale-epoch marks are ignored, and a racing populate that captured the
47
+ # pre-bump epoch is ignored too); the leftover rows are reclaimed by the next reset!.
47
48
  def reset!
48
49
  ensure_table!
50
+ view_class.metadata.bump_fresh_set_generation!
49
51
  scope.delete_all
50
52
  end
51
53
 
54
+ # The current fresh-set epoch, read into Ruby. A populate captures this BEFORE its source read and
55
+ # stamps its marks with it, so a widen committing during the read advances the epoch and leaves the
56
+ # mark un-served. (all_fresh? compares against the epoch in-SQL via current_generation_scope.)
57
+ def current_generation
58
+ view_class.metadata.fresh_set_generation
59
+ end
60
+
52
61
  # The partition key tuples a query touches, or nil unless the conditions are
53
62
  # an exact match on the GROUP BY columns (the only case the fast path serves).
54
- sig { params(view_class: ViewClass, args: T::Array[T.untyped]).returns(T.nilable(T::Array[KeyTuple])) }
55
63
  def self.keys_from(view_class, args)
56
64
  conditions = single_hash(args)
57
65
  return nil if conditions.nil?
@@ -63,7 +71,6 @@ module ActiveRecord
63
71
  value_lists.nil? ? nil : cartesian(value_lists)
64
72
  end
65
73
 
66
- sig { params(args: T::Array[T.untyped]).returns(T.nilable(T::Hash[T.untyped, T.untyped])) }
67
74
  def self.single_hash(args)
68
75
  return nil unless args.length == 1
69
76
 
@@ -71,10 +78,6 @@ module ActiveRecord
71
78
  conditions.is_a?(Hash) ? conditions : nil
72
79
  end
73
80
 
74
- sig do
75
- params(conditions: T::Hash[T.untyped, T.untyped], group_keys: T::Array[String])
76
- .returns(T.nilable(T::Array[T::Array[String]]))
77
- end
78
81
  def self.key_value_lists(conditions, group_keys)
79
82
  normalized = conditions.transform_keys(&:to_s)
80
83
  return nil unless normalized.keys.sort == group_keys.sort
@@ -82,7 +85,6 @@ module ActiveRecord
82
85
  group_keys.map { |column| Array(normalized.fetch(column)).map(&:to_s) }
83
86
  end
84
87
 
85
- sig { params(value_lists: T::Array[T::Array[String]]).returns(T.nilable(T::Array[KeyTuple])) }
86
88
  def self.cartesian(value_lists)
87
89
  return nil if value_lists.any?(&:empty?)
88
90
 
@@ -93,34 +95,33 @@ module ActiveRecord
93
95
 
94
96
  private
95
97
 
96
- sig { returns(String) }
97
- def view_key
98
- @view_class.view_key
98
+ def record_class
99
+ PartitionRecord
99
100
  end
100
101
 
101
- sig { params(key_tuple: KeyTuple).returns(String) }
102
- def serialize(key_tuple)
103
- key_tuple.map(&:to_s).to_json
102
+ def table_name
103
+ ::ActiveRecord::Materialized.partition_table_name
104
104
  end
105
105
 
106
- sig { returns(::ActiveRecord::Relation) }
107
- def scope
108
- PartitionRecord.where(view_name: view_key)
106
+ def define_columns(table)
107
+ table.integer :generation, null: false, default: 0
108
+ table.datetime :created_at, null: false
109
109
  end
110
110
 
111
- sig { void }
112
- def ensure_table!
113
- connection = @view_class.connection
114
- return if PartitionRecord.table_exists?
111
+ # Lazily add the fresh-set epoch column (#120) to a partition table provisioned by an earlier
112
+ # version, so an app migrated from before this column picks it up without a new migration.
113
+ def ensure_columns!(connection)
114
+ return if record_class.column_names.include?("generation")
115
115
 
116
- table = ::ActiveRecord::Materialized.partition_table_name
117
- connection.create_table(table) do |t|
118
- t.string :view_name, null: false
119
- t.string :partition_key, null: false
120
- t.datetime :created_at, null: false
121
- end
122
- connection.add_index(table, %i[view_name partition_key], unique: true)
123
- PartitionRecord.reset_column_information
116
+ connection.add_column(table_name, :generation, :integer, null: false, default: 0)
117
+ record_class.reset_column_information
118
+ end
119
+
120
+ # The view's current epoch as a one-column relation, so all_fresh? can filter marks by it in a
121
+ # single query (generation IN (SELECT ...)). An absent metadata row yields no match — nothing is
122
+ # fresh which is correct: partition marks only ever exist once maintenance has created the row.
123
+ def current_generation_scope
124
+ MetadataRecord.where(view_name: view_key).select(:fresh_set_generation)
124
125
  end
125
126
  end
126
127
  end