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
|
|
@@ -13,19 +12,16 @@ module ActiveRecord
|
|
|
13
12
|
# @see Materialized::Configuration the configurable settings
|
|
14
13
|
module Materialized
|
|
15
14
|
class << self
|
|
16
|
-
|
|
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 =
|
|
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
|
-
|
|
50
|
-
def metadata_table_name
|
|
51
|
-
configuration.metadata_table_name
|
|
52
|
-
end
|
|
44
|
+
def metadata_table_name = configuration.metadata_table_name
|
|
53
45
|
|
|
54
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
8
|
+
include ConfigurableTableName
|
|
10
9
|
|
|
11
|
-
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
@view_class.view_key
|
|
98
|
+
def record_class
|
|
99
|
+
PartitionRecord
|
|
99
100
|
end
|
|
100
101
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
key_tuple.map(&:to_s).to_json
|
|
102
|
+
def table_name
|
|
103
|
+
::ActiveRecord::Materialized.partition_table_name
|
|
104
104
|
end
|
|
105
105
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
return if
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|